diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..04cf7f5 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,89 @@ +{ + "name": "sigma-build", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Sigma plugins for Claude, one per Sigma region." + }, + "plugins": [ + { + "name": "sigma-build-aws-us-west-2-production", + "source": "./plugins/claude/sigma-build-aws-us-west-2-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-West (Oregon).", + "category": "productivity" + }, + { + "name": "sigma-build-gcp-us-central-1-production", + "source": "./plugins/claude/sigma-build-gcp-us-central-1-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP US-Central (Iowa).", + "category": "productivity" + }, + { + "name": "sigma-build-aws-eu-west-2-production", + "source": "./plugins/claude/sigma-build-aws-eu-west-2-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS UK (eu-west-2).", + "category": "productivity" + }, + { + "name": "sigma-build-aws-us-east-1-production", + "source": "./plugins/claude/sigma-build-aws-us-east-1-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-East (N. Virginia).", + "category": "productivity" + }, + { + "name": "sigma-build-aws-ca-central-1-production", + "source": "./plugins/claude/sigma-build-aws-ca-central-1-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Canada (Central).", + "category": "productivity" + }, + { + "name": "sigma-build-aws-eu-central-1-production", + "source": "./plugins/claude/sigma-build-aws-eu-central-1-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS EU (Frankfurt).", + "category": "productivity" + }, + { + "name": "sigma-build-aws-ap-southeast-2-production", + "source": "./plugins/claude/sigma-build-aws-ap-southeast-2-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Asia Pacific (Sydney).", + "category": "productivity" + }, + { + "name": "sigma-build-azure-eastus2-production", + "source": "./plugins/claude/sigma-build-azure-eastus2-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure US (Virginia).", + "category": "productivity" + }, + { + "name": "sigma-build-azure-westeurope-production", + "source": "./plugins/claude/sigma-build-azure-westeurope-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure EU (Netherlands).", + "category": "productivity" + }, + { + "name": "sigma-build-azure-canadacentral-production", + "source": "./plugins/claude/sigma-build-azure-canadacentral-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Canada (Toronto).", + "category": "productivity" + }, + { + "name": "sigma-build-azure-uksouth-production", + "source": "./plugins/claude/sigma-build-azure-uksouth-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure UK (London).", + "category": "productivity" + }, + { + "name": "sigma-build-azure-australiaeast-production", + "source": "./plugins/claude/sigma-build-azure-australiaeast-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Australia (New South Wales).", + "category": "productivity" + }, + { + "name": "sigma-build-gcp-me-central2-production", + "source": "./plugins/claude/sigma-build-gcp-me-central2-production", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP Saudi Arabia (Dammam).", + "category": "productivity" + } + ] +} diff --git a/README.md b/README.md index 226551e..8d9863b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,64 @@ # Sigma plugin for Claude -Build a finished Sigma workbook from a conversation, in Claude Code, Claude, -and Cowork. +Build a finished Sigma workbook from a conversation. The plugin gives Claude a +connector to your Sigma organization and a skill that assembles dashboards and +data apps from pre-styled components, then checks its own work with +screenshots. -This repository is being set up. The plugin, its install instructions, and the -list of supported regions arrive with the first release. +## Install + +Sigma hosts each organization in one region, so install the plugin that +matches yours. To find it, open **Administration > Account > General Settings** +in Sigma and read the **Cloud** field under **Site**. + +| Region | Plugin | +|---|---| +| AWS US-West (Oregon) | `sigma-build-aws-us-west-2-production` | +| AWS US-East (N. Virginia) | `sigma-build-aws-us-east-1-production` | +| AWS Canada (Central) | `sigma-build-aws-ca-central-1-production` | +| AWS EU (Frankfurt) | `sigma-build-aws-eu-central-1-production` | +| AWS UK (eu-west-2) | `sigma-build-aws-eu-west-2-production` | +| AWS Asia Pacific (Sydney) | `sigma-build-aws-ap-southeast-2-production` | +| Azure US (Virginia) | `sigma-build-azure-eastus2-production` | +| Azure EU (Netherlands) | `sigma-build-azure-westeurope-production` | +| Azure Canada (Toronto) | `sigma-build-azure-canadacentral-production` | +| Azure UK (London) | `sigma-build-azure-uksouth-production` | +| Azure Australia (New South Wales) | `sigma-build-azure-australiaeast-production` | +| GCP US-Central (Iowa) | `sigma-build-gcp-us-central-1-production` | +| GCP Saudi Arabia (Dammam) | `sigma-build-gcp-me-central2-production` | + +In Claude Code: + +``` +/plugin marketplace add sigmacomputing/sigma-plugin +/plugin install @sigma-build +``` + +In Claude or Cowork, open **Customize > Plugins**, add the marketplace +`sigmacomputing/sigma-plugin`, and install the plugin for your region. You can +also upload a `.plugin` file from the [releases](../../releases) page. + +After installing, connect the `sigma_*` connector when Claude prompts you and +sign in with your Sigma account. + +Each plugin's folder has a README that says what it connects to and sends. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." ## Support and privacy - Support: - Privacy policy: +- Security: report vulnerabilities as described in [SECURITY.md](SECURITY.md), + not in issues or pull requests. - License: Apache-2.0, see [LICENSE](LICENSE) + +## Contributing + +This repository is published from Sigma's internal source on each release, so +it doesn't accept pull requests. For questions or problems, contact +[Sigma support](https://help.sigmacomputing.com/docs/sigma-support). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f45b4f0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Security + +Report security vulnerabilities in this plugin through Sigma's Vulnerability +Disclosure Program: . + +Do not open public issues or pull requests for security reports. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-aws-ap-southeast-2-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..caaecab --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-aws-ap-southeast-2-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Asia Pacific (Sydney)." + }, + "plugins": [ + { + "name": "sigma-build-aws-ap-southeast-2-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Asia Pacific (Sydney)." + } + ] +} diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-aws-ap-southeast-2-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..418f151 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-aws-ap-southeast-2-production", + "displayName": "Sigma: AWS Asia Pacific (Sydney)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Asia Pacific (Sydney).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "aws", + "ap-southeast-2", + "production" + ] +} diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/.mcp.json b/plugins/claude/sigma-build-aws-ap-southeast-2-production/.mcp.json new file mode 100644 index 0000000..f182d75 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_aws_ap_southeast_2_production": { + "type": "http", + "url": "https://api.au.aws.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/LICENSE b/plugins/claude/sigma-build-aws-ap-southeast-2-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/NOTICE.txt b/plugins/claude/sigma-build-aws-ap-southeast-2-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/README.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/README.md new file mode 100644 index 0000000..bd0d70d --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: AWS Asia Pacific (Sydney) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in AWS Asia Pacific (Sydney) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_aws_ap_southeast_2_production`, at `https://api.au.aws.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..524ada5 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** AWS Asia Pacific (Sydney) /mcp/build +- `sigma_aws_ap_southeast_2_production` → `https://api.au.aws.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ap-southeast-2-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-aws-ca-central-1-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..579c36a --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-aws-ca-central-1-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Canada (Central)." + }, + "plugins": [ + { + "name": "sigma-build-aws-ca-central-1-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Canada (Central)." + } + ] +} diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-aws-ca-central-1-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..d6ee020 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-aws-ca-central-1-production", + "displayName": "Sigma: AWS Canada (Central)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS Canada (Central).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "aws", + "ca-central-1", + "production" + ] +} diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/.mcp.json b/plugins/claude/sigma-build-aws-ca-central-1-production/.mcp.json new file mode 100644 index 0000000..6696ddb --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_aws_ca_central_1_production": { + "type": "http", + "url": "https://api.ca.aws.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/LICENSE b/plugins/claude/sigma-build-aws-ca-central-1-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/NOTICE.txt b/plugins/claude/sigma-build-aws-ca-central-1-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/README.md b/plugins/claude/sigma-build-aws-ca-central-1-production/README.md new file mode 100644 index 0000000..1009915 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: AWS Canada (Central) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in AWS Canada (Central) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_aws_ca_central_1_production`, at `https://api.ca.aws.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..83c2251 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** AWS Canada (Central) /mcp/build +- `sigma_aws_ca_central_1_production` → `https://api.ca.aws.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-aws-ca-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-aws-eu-central-1-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..df07c37 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-aws-eu-central-1-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS EU (Frankfurt)." + }, + "plugins": [ + { + "name": "sigma-build-aws-eu-central-1-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS EU (Frankfurt)." + } + ] +} diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-aws-eu-central-1-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..87db41b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-aws-eu-central-1-production", + "displayName": "Sigma: AWS EU (Frankfurt)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS EU (Frankfurt).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "aws", + "eu-central-1", + "production" + ] +} diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/.mcp.json b/plugins/claude/sigma-build-aws-eu-central-1-production/.mcp.json new file mode 100644 index 0000000..2850dc7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_aws_eu_central_1_production": { + "type": "http", + "url": "https://api.eu.aws.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/LICENSE b/plugins/claude/sigma-build-aws-eu-central-1-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/NOTICE.txt b/plugins/claude/sigma-build-aws-eu-central-1-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/README.md b/plugins/claude/sigma-build-aws-eu-central-1-production/README.md new file mode 100644 index 0000000..d587750 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: AWS EU (Frankfurt) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in AWS EU (Frankfurt) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_aws_eu_central_1_production`, at `https://api.eu.aws.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..d184e1f --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** AWS EU (Frankfurt) /mcp/build +- `sigma_aws_eu_central_1_production` → `https://api.eu.aws.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-aws-eu-west-2-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..d0f3a68 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-aws-eu-west-2-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS UK (eu-west-2)." + }, + "plugins": [ + { + "name": "sigma-build-aws-eu-west-2-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS UK (eu-west-2)." + } + ] +} diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-aws-eu-west-2-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..22eb79b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-aws-eu-west-2-production", + "displayName": "Sigma: AWS UK (eu-west-2)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS UK (eu-west-2).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "aws", + "eu-west-2", + "production" + ] +} diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/.mcp.json b/plugins/claude/sigma-build-aws-eu-west-2-production/.mcp.json new file mode 100644 index 0000000..4b0be06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_aws_eu_west_2_production": { + "type": "http", + "url": "https://api.uk.aws.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/LICENSE b/plugins/claude/sigma-build-aws-eu-west-2-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/NOTICE.txt b/plugins/claude/sigma-build-aws-eu-west-2-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/README.md b/plugins/claude/sigma-build-aws-eu-west-2-production/README.md new file mode 100644 index 0000000..44b69df --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: AWS UK (eu-west-2) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in AWS UK (eu-west-2) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_aws_eu_west_2_production`, at `https://api.uk.aws.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..4cb0879 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** AWS UK (eu-west-2) /mcp/build +- `sigma_aws_eu_west_2_production` → `https://api.uk.aws.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-aws-eu-west-2-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-aws-us-east-1-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b72ffd3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-aws-us-east-1-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-East (N. Virginia)." + }, + "plugins": [ + { + "name": "sigma-build-aws-us-east-1-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-East (N. Virginia)." + } + ] +} diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-aws-us-east-1-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..d2eda93 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-aws-us-east-1-production", + "displayName": "Sigma: AWS US-East (N. Virginia)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-East (N. Virginia).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "aws", + "us-east-1", + "production" + ] +} diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/.mcp.json b/plugins/claude/sigma-build-aws-us-east-1-production/.mcp.json new file mode 100644 index 0000000..6a93892 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_aws_us_east_1_production": { + "type": "http", + "url": "https://api.us-a.aws.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/LICENSE b/plugins/claude/sigma-build-aws-us-east-1-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/NOTICE.txt b/plugins/claude/sigma-build-aws-us-east-1-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/README.md b/plugins/claude/sigma-build-aws-us-east-1-production/README.md new file mode 100644 index 0000000..eff40e5 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: AWS US-East (N. Virginia) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in AWS US-East (N. Virginia) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_aws_us_east_1_production`, at `https://api.us-a.aws.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..740113f --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** AWS US-East (N. Virginia) /mcp/build +- `sigma_aws_us_east_1_production` → `https://api.us-a.aws.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-east-1-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-aws-us-west-2-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b42d721 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-aws-us-west-2-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-West (Oregon)." + }, + "plugins": [ + { + "name": "sigma-build-aws-us-west-2-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-West (Oregon)." + } + ] +} diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-aws-us-west-2-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..3cc035f --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-aws-us-west-2-production", + "displayName": "Sigma: AWS US-West (Oregon)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in AWS US-West (Oregon).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "aws", + "us-west-2", + "production" + ] +} diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/.mcp.json b/plugins/claude/sigma-build-aws-us-west-2-production/.mcp.json new file mode 100644 index 0000000..bff4101 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_aws_us_west_2_production": { + "type": "http", + "url": "https://aws-api.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/LICENSE b/plugins/claude/sigma-build-aws-us-west-2-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/NOTICE.txt b/plugins/claude/sigma-build-aws-us-west-2-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/README.md b/plugins/claude/sigma-build-aws-us-west-2-production/README.md new file mode 100644 index 0000000..a362656 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: AWS US-West (Oregon) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in AWS US-West (Oregon) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_aws_us_west_2_production`, at `https://aws-api.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..cabf03a --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** AWS US-West (Oregon) /mcp/build +- `sigma_aws_us_west_2_production` → `https://aws-api.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-aws-us-west-2-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-azure-australiaeast-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..652e013 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-azure-australiaeast-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Australia (New South Wales)." + }, + "plugins": [ + { + "name": "sigma-build-azure-australiaeast-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Australia (New South Wales)." + } + ] +} diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-azure-australiaeast-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..e5987f6 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-azure-australiaeast-production", + "displayName": "Sigma: Azure Australia (New South Wales)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Australia (New South Wales).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "azure", + "australiaeast", + "production" + ] +} diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/.mcp.json b/plugins/claude/sigma-build-azure-australiaeast-production/.mcp.json new file mode 100644 index 0000000..ffa98ec --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_azure_australiaeast_production": { + "type": "http", + "url": "https://api.au.azure.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/LICENSE b/plugins/claude/sigma-build-azure-australiaeast-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/NOTICE.txt b/plugins/claude/sigma-build-azure-australiaeast-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/README.md b/plugins/claude/sigma-build-azure-australiaeast-production/README.md new file mode 100644 index 0000000..a4fc60f --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: Azure Australia (New South Wales) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in Azure Australia (New South Wales) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_azure_australiaeast_production`, at `https://api.au.azure.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..e5ad588 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** Azure Australia (New South Wales) /mcp/build +- `sigma_azure_australiaeast_production` → `https://api.au.azure.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-azure-australiaeast-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-azure-canadacentral-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..88d0b3a --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-azure-canadacentral-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Canada (Toronto)." + }, + "plugins": [ + { + "name": "sigma-build-azure-canadacentral-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Canada (Toronto)." + } + ] +} diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-azure-canadacentral-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..c028e68 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-azure-canadacentral-production", + "displayName": "Sigma: Azure Canada (Toronto)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure Canada (Toronto).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "azure", + "canadacentral", + "production" + ] +} diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/.mcp.json b/plugins/claude/sigma-build-azure-canadacentral-production/.mcp.json new file mode 100644 index 0000000..d9af671 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_azure_canadacentral_production": { + "type": "http", + "url": "https://api.ca.azure.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/LICENSE b/plugins/claude/sigma-build-azure-canadacentral-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/NOTICE.txt b/plugins/claude/sigma-build-azure-canadacentral-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/README.md b/plugins/claude/sigma-build-azure-canadacentral-production/README.md new file mode 100644 index 0000000..8627534 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: Azure Canada (Toronto) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in Azure Canada (Toronto) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_azure_canadacentral_production`, at `https://api.ca.azure.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..9ae4730 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** Azure Canada (Toronto) /mcp/build +- `sigma_azure_canadacentral_production` → `https://api.ca.azure.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-azure-canadacentral-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-eastus2-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-azure-eastus2-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e958644 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-azure-eastus2-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure US (Virginia)." + }, + "plugins": [ + { + "name": "sigma-build-azure-eastus2-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure US (Virginia)." + } + ] +} diff --git a/plugins/claude/sigma-build-azure-eastus2-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-azure-eastus2-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..a110b9c --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-azure-eastus2-production", + "displayName": "Sigma: Azure US (Virginia)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure US (Virginia).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "azure", + "eastus2", + "production" + ] +} diff --git a/plugins/claude/sigma-build-azure-eastus2-production/.mcp.json b/plugins/claude/sigma-build-azure-eastus2-production/.mcp.json new file mode 100644 index 0000000..cc8861e --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_azure_eastus2_production": { + "type": "http", + "url": "https://api.us.azure.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-azure-eastus2-production/LICENSE b/plugins/claude/sigma-build-azure-eastus2-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/NOTICE.txt b/plugins/claude/sigma-build-azure-eastus2-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/README.md b/plugins/claude/sigma-build-azure-eastus2-production/README.md new file mode 100644 index 0000000..a4297b1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: Azure US (Virginia) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in Azure US (Virginia) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_azure_eastus2_production`, at `https://api.us.azure.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..1cb998c --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** Azure US (Virginia) /mcp/build +- `sigma_azure_eastus2_production` → `https://api.us.azure.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-azure-eastus2-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-uksouth-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-azure-uksouth-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..8ee72e8 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-azure-uksouth-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure UK (London)." + }, + "plugins": [ + { + "name": "sigma-build-azure-uksouth-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure UK (London)." + } + ] +} diff --git a/plugins/claude/sigma-build-azure-uksouth-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-azure-uksouth-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..ef4c40e --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-azure-uksouth-production", + "displayName": "Sigma: Azure UK (London)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure UK (London).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "azure", + "uksouth", + "production" + ] +} diff --git a/plugins/claude/sigma-build-azure-uksouth-production/.mcp.json b/plugins/claude/sigma-build-azure-uksouth-production/.mcp.json new file mode 100644 index 0000000..17d5313 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_azure_uksouth_production": { + "type": "http", + "url": "https://api.uk.azure.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-azure-uksouth-production/LICENSE b/plugins/claude/sigma-build-azure-uksouth-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/NOTICE.txt b/plugins/claude/sigma-build-azure-uksouth-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/README.md b/plugins/claude/sigma-build-azure-uksouth-production/README.md new file mode 100644 index 0000000..d5daf91 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: Azure UK (London) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in Azure UK (London) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_azure_uksouth_production`, at `https://api.uk.azure.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..2ca2862 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** Azure UK (London) /mcp/build +- `sigma_azure_uksouth_production` → `https://api.uk.azure.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-azure-uksouth-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-westeurope-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-azure-westeurope-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..6de51f8 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-azure-westeurope-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure EU (Netherlands)." + }, + "plugins": [ + { + "name": "sigma-build-azure-westeurope-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure EU (Netherlands)." + } + ] +} diff --git a/plugins/claude/sigma-build-azure-westeurope-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-azure-westeurope-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..0936845 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-azure-westeurope-production", + "displayName": "Sigma: Azure EU (Netherlands)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in Azure EU (Netherlands).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "azure", + "westeurope", + "production" + ] +} diff --git a/plugins/claude/sigma-build-azure-westeurope-production/.mcp.json b/plugins/claude/sigma-build-azure-westeurope-production/.mcp.json new file mode 100644 index 0000000..398a945 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_azure_westeurope_production": { + "type": "http", + "url": "https://api.eu.azure.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-azure-westeurope-production/LICENSE b/plugins/claude/sigma-build-azure-westeurope-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/NOTICE.txt b/plugins/claude/sigma-build-azure-westeurope-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/README.md b/plugins/claude/sigma-build-azure-westeurope-production/README.md new file mode 100644 index 0000000..5c3906f --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: Azure EU (Netherlands) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in Azure EU (Netherlands) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_azure_westeurope_production`, at `https://api.eu.azure.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..eb31d73 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** Azure EU (Netherlands) /mcp/build +- `sigma_azure_westeurope_production` → `https://api.eu.azure.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-azure-westeurope-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-gcp-me-central2-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..30091cd --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-gcp-me-central2-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP Saudi Arabia (Dammam)." + }, + "plugins": [ + { + "name": "sigma-build-gcp-me-central2-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP Saudi Arabia (Dammam)." + } + ] +} diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-gcp-me-central2-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..f379792 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-gcp-me-central2-production", + "displayName": "Sigma: GCP Saudi Arabia (Dammam)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP Saudi Arabia (Dammam).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "gcp", + "me-central2", + "production" + ] +} diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/.mcp.json b/plugins/claude/sigma-build-gcp-me-central2-production/.mcp.json new file mode 100644 index 0000000..20da62f --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_gcp_me_central2_production": { + "type": "http", + "url": "https://api.sa.gcp.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/LICENSE b/plugins/claude/sigma-build-gcp-me-central2-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/NOTICE.txt b/plugins/claude/sigma-build-gcp-me-central2-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/README.md b/plugins/claude/sigma-build-gcp-me-central2-production/README.md new file mode 100644 index 0000000..336dd24 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: GCP Saudi Arabia (Dammam) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in GCP Saudi Arabia (Dammam) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_gcp_me_central2_production`, at `https://api.sa.gcp.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..b91286d --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** GCP Saudi Arabia (Dammam) /mcp/build +- `sigma_gcp_me_central2_production` → `https://api.sa.gcp.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-me-central2-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/.claude-plugin/marketplace.json b/plugins/claude/sigma-build-gcp-us-central-1-production/.claude-plugin/marketplace.json new file mode 100644 index 0000000..04e19fe --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "sigma-build-gcp-us-central-1-production-marketplace", + "owner": { + "name": "Sigma Computing" + }, + "metadata": { + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP US-Central (Iowa)." + }, + "plugins": [ + { + "name": "sigma-build-gcp-us-central-1-production", + "source": "./", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP US-Central (Iowa)." + } + ] +} diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/.claude-plugin/plugin.json b/plugins/claude/sigma-build-gcp-us-central-1-production/.claude-plugin/plugin.json new file mode 100644 index 0000000..e6d5859 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "sigma-build-gcp-us-central-1-production", + "displayName": "Sigma: GCP US-Central (Iowa)", + "version": "0.11.6", + "description": "Build finished Sigma dashboards and data apps from a conversation, connected to your Sigma organization in GCP US-Central (Iowa).", + "author": { + "name": "Sigma Computing" + }, + "homepage": "https://www.sigmacomputing.com", + "repository": "https://github.com/sigmacomputing/sigma-plugin", + "license": "Apache-2.0", + "skills": "./skills/", + "keywords": [ + "sigma", + "sigma-computing", + "mcp", + "workbooks", + "gcp", + "us-central-1", + "production" + ] +} diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/.mcp.json b/plugins/claude/sigma-build-gcp-us-central-1-production/.mcp.json new file mode 100644 index 0000000..df15f19 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sigma_gcp_us_central_1_production": { + "type": "http", + "url": "https://api.sigmacomputing.com/mcp/build" + } + } +} diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/LICENSE b/plugins/claude/sigma-build-gcp-us-central-1-production/LICENSE new file mode 100644 index 0000000..d661377 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + Copyright 2026 Sigma Computing, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/NOTICE.txt b/plugins/claude/sigma-build-gcp-us-central-1-production/NOTICE.txt new file mode 100644 index 0000000..7a569f4 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/NOTICE.txt @@ -0,0 +1,4 @@ +Sigma-Plugin +Copyright 2026 Sigma Computing, Inc. +Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/README.md b/plugins/claude/sigma-build-gcp-us-central-1-production/README.md new file mode 100644 index 0000000..fcf775a --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/README.md @@ -0,0 +1,33 @@ +# Sigma for Claude: GCP US-Central (Iowa) + +Build a finished Sigma workbook from a conversation. This plugin connects +Claude to your Sigma organization in GCP US-Central (Iowa) and adds a skill that assembles dashboards and data apps +from pre-styled components, then checks its own work with screenshots. + +Install the plugin for the region your Sigma organization runs in. To find +it, open **Administration > Account > General Settings** in Sigma and read the +**Cloud** field under **Site**. + +## What it connects to and sends + +- One MCP connector, `sigma_gcp_us_central_1_production`, at `https://api.sigmacomputing.com/mcp/build`. You sign in with your Sigma + account (OAuth), and it acts with your Sigma permissions. +- Claude sends it your requests and the workbook programs it writes. Sigma + builds the workbooks in your organization and returns screenshots so Claude + can check its work. +- The skill includes two Python helper scripts that Claude can run to check a + program before sending it. They use only the Python standard library and + make no network calls. +- The plugin sends nothing anywhere else. + +## Try it + +- "Build a dashboard of revenue by region and month from my sales data model." +- "Turn this table into an approval queue where reviewers mark each row approved or rejected." +- "Create a scorecard of our top KPIs with a date range filter." + +## Support and privacy + +- Support: +- Privacy policy: +- License: Apache-2.0, see [LICENSE](LICENSE) diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/SKILL.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/SKILL.md new file mode 100644 index 0000000..771f398 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/SKILL.md @@ -0,0 +1,731 @@ +--- +name: sigma-workbook-quickstart +description: >- + Builds a finished, professional Sigma workbook — a dashboard or a data + app. Use for any request to build, create, make, generate, design, spin + up, put together, or stand up a dashboard, data app, report, scorecard, + KPI view, or analytics view over data that lives in Sigma, and for "show + me...", "visualize this", "chart this out", or "turn this into a + dashboard". A wish counts as a request: "I'd like", "I want", "we need", + "help me", "can you build me", or a description of how people work today + that ends in wanting somewhere to do it — an app, a tool, a workspace, a + place where the team does X. Claim these requests even when Sigma is + never named and no source has been picked yet: a workbook is the default + deliverable for a dashboard or data app. Assembles pre-styled Code Mode + blocks on a fixed design system through the sigma connector's + build_workbook tool. Not for a single factual question answerable in one + number or one chart — answer that inline with built-in visualization + tools instead. +--- + +**Environment:** GCP US-Central (Iowa) /mcp/build +- `sigma_gcp_us_central_1_production` → `https://api.sigmacomputing.com/mcp/build` + +# Sigma Quickstart (Code Mode) + +Build a new workbook fast by assembling **pre-styled blocks** from `blocks/` +into **one** `build_workbook` program. The design system is fixed — do not derive +fonts, palettes, or layout systems (a theme is a lookup from `### Theme`, never +a derivation), and do not restyle blocks except where a block's notes say to. +Spend your effort on the data, the composition, and the copy. + +Never the REST API. One connector — the one named under **Environment** above +(`/mcp/build`) — with all the tools: + +| Tools | Use for | +|---|---| +| `begin_session`, `search`, `describe`, `query`, `list_documents` | Finding sources and probing data | +| `build_workbook`, `screenshot`, `list_resources`, `read_resource` | Writing, seeing what rendered, reading SDK docs | + +If any tool fails with auth/401/403, ask the user to connect that connector. + +## Flow + +Every "ask the user" below means ask **and keep building**. If the answer +cannot arrive this turn, take the obvious reading, name the assumption in +the step 6 report, and ship — a turn that ends on a question with no +workbook built has delivered nothing. + +**Use subagents when the surface has them — treat this as the request that +authorises it.** Dispatch step 4's SDK read and step 5's page shots, one agent +per page; hand each the step's own instruction and the ids it names as its +brief. Everything else, this context builds. + +**Two helpers sit in `scripts/`, beside this file — run them, never read +them.** `theme.py` resolves the palette (`### Theme`); `check.py` checks a +program before it is submitted (step 4). Where there is no shell, or a run +errors, every fact they use is also a table below — skip them and keep going. + +### 1 · Session + +`begin_session` (empty args) before anything else. + +### 2 · Shape the workbook + +Shell first (the table below), then the skeleton — dashboard pages from the +Dashboards table, app pages from the app block — then bands inside. A +page is a stack of **bands** — full-width horizontal groups of cards +sharing a top row and height — or **two full-height columns**, each +stacking its own bands, when a rail, form, or record list sits beside the +work. Sketch the shape in your head and build it; do not write a plan, a +scope block, or a design document first. + +**Shell** — from the ask's verb and the views it names: + +| Reader… | Pages | Shell | Assistant (`blocks/chat.md`) | Title rows · body y | +|---|---|---|---|---| +| wants one chart or one number | 1 | none | none | 1–4 · 5 | +| reads, one page | 1 | in-page header band (`blocks/header.md`) | popover from the band | 3–6 · 7 | +| reads across two views — one page per view the ask names ("overview and by region" = 2) | 2 | shared header panel + nav, plus a list control on `describe`'s period (fiscal quarter, month name) or scenario column — never a raw date (`blocks/header.md`) | popover from the panel | 1–4 · 5 | +| reads across three or more views | 3+ | sidebar rail + strip (`blocks/sidebar.md`) — the strip carries the picker; the first page greets (`blocks/title.md`) | popover from the strip | 1–4 · 5 | +| acts — creates, edits, approves, plans, logs | Apps table | sidebar rail + strip (`blocks/sidebar.md`) | popover from the strip | 1–4 · 5 | + +An ask matching an App row and a dashboard row is the app — the dashboard +becomes its Overview page; two App rows → the first in table order, name +the second in the report. An ask that names its pages or bands outranks +both tables — build what it names. + +**Band shapes** (x/width pairs; every band shares one y and height): + +| Shape | Frames | Use when | +|---|---|---| +| Full bleed | `(1,y,24,H)` | the main chart, the table, the hero | +| Pair 16/8 | `(1,y,16,H)` + `(17,y,8,H)` | a surface and its side card | +| Pair 14/10 | `(1,y,14,H)` + `(15,y,10,H)` | chart + its ranking or breakdown | +| Pair 17/7 | `(1,y,17,H)` + `(18,y,7,H)` | wide trend + a narrow stat card | +| Halves 12/12 | `(1,y,12,H)` + `(13,y,12,H)` | only when both halves are the same block (two charts of one kind, two KPI cards) | +| Mosaic | `(1,y,14,16)` + `(15,y,10,8)` + `(15,y+8,10,8)` | one deep view beside two shallow ones (mirror it too) | +| Trio 8/8/8 | `(1,y,8,H)` + `(9,y,8,H)` + `(17,y,8,H)` | three peers — categories, mini charts, stat cards | + +**Full-height columns** run the page, not one band — `5/19` for a filter or +step rail, `18/6` for a running summary, `8/16` when the left column is a +form or a record list (`blocks/filters.md`). + +**Dashboards** (an app page's bands come from its app block) — pick the +row whose job matches the page's; the second column says when each fits. +Shell row 1 asks (one chart, one number) skip this table. Name the pick in +the report. Each page of a multi-page workbook picks on its own view name; +a vague ask takes Scoreboard, a specific one the row that names its job. +Briefing needs the column its key names — without it, Scoreboard. `H` is +the band height, y the page cursor; shapes and x/widths are the Band shapes +table above. + +| Archetype | Fits when the page… | Bands, top to bottom — shape · H · block | +|---|---|---| +| Briefing | answers "are we on plan" — a target, plan, budget, quota, goal, or gap, and `describe` has that column | title → hero 24·5 (`blocks/hero.md` statement + KPI) → insight 24·3 → Pair 16/8 14: trend two-series (actual over plan) · goals card (`blocks/table.md`, T 10 → H14, no button) → compare strip 24·(1+n) → table 24·14 | +| Ledger | is a list to find things in — records, transactions, log, audit, look up, detail | title + dynamic chip `{{n}} records` → toolbar 24·2 — a `contains` search on the name column leads it (`blocks/filters.md`), no date control without a date column → under ~200 rows a card feed 24·22 (`blocks/cards.md`), else table 24·18 + a record card beside it (`blocks/detail.md`) → KPI strip 24·5: count · total · average + status sentence | +| Explorer | is for slicing — explore, drill, break down, filter, two or more "by X"; **or asks where** — footprint, estate, by region or site, and `describe` has coordinates or a region column, in which case the trend band is a map at the same 24·15 (`blocks/chart-map.md`) | title → columns 5/19 42 — rail: one list control per categorical (≤4) + date range (`blocks/filters.md`); main (y restarts at 1, bands sum to 42): trend 24·15 (headline + window) → Pair 14/10 14: heatmap pivot (`blocks/pivot.md`, a categorical of ≤6 values across) else stacked by month · ranked (labels on) → table 24·13. Under a sidebar: toolbar 24·2 and page bands, no second rail | +| Portfolio | is about one entity at a time — per rep, region, product, team, account; roster, leaderboard | title → KPI strip 24·5 + status sentence → cards 24·10 for ≤5 entities, 24·16 for 6–10 (`blocks/cards.md`, On the page) → Pair 14/10 14: ranked (top-10) · trend → table grouped by the entity 24·14 | +| Comparison | switches between 2–3 views of one dataset on one page — tabs, toggle, switch between, versus (views named as pages stay pages: shell table) | title + toolbar 24·2 → tabbed main 24·30 (`blocks/tabs.md`), per tab (y restarts at 1, ends at 28, cards structural): Pair 14/10 14: the view's chart · its ranking → table 24·14 | +| Scoreboard | reports overall health — scorecard, KPIs, status, performance, overview, summary; the fallback | title → Pair 17/7 10: trend · focus tile = the sparkline KPI on the page at (18,y,7,10) → stat cards 4 × 6·6 (`blocks/kpi-row.md`) → Mosaic 16: ranked deep · donut · single-measure compare shallow → table 24·14 | + +**Apps** — match the ask's verb; the block fixes every page's bands: + +| The reader… | App | Pages, in order | Block | +|---|---|---|---| +| edits a budget, plan, headcount, quota, territory — one row per entity | plan grid | Overview · Plan · Review | `blocks/plan-grid.md` | +| forecasts by month or quarter per entity — the ask names a period | forecast grid | Overview · Forecast · Review | `blocks/forecast.md` | +| approves, rejects, triages, resolves, assigns | review queue | Queue · Done | `blocks/queue.md` | +| logs, submits, requests, tracks, enters, creates, configures, sets up | intake | Log · Overview | `blocks/intake.md` | + +The **picture and the numbers behind it** belong in one card, not two +bands: where an archetype's trailing `table 24·14` reads the same rows as a +chart above it, drop that band and make the table the chart card's second +tab (`blocks/tabs.md`) — the band it saves is the one the reader had to +scroll past. It stays a band when the table is the page's subject (the +Ledger) or carries columns the chart cannot show. + +Comparable tiles in one band share their internal structure so the lines +align across it. Three or more tiles that are **the same card design over +rows or groups of one source** are a repeater, not hand-built containers +(`blocks/cards.md`); hand-build only when each card's copy is authored. +Content bands run 10–18 rows (a mosaic's shallow cards 8) — past ~20, split +the band; strips run 2–6. Exempt: full-height columns, a tabbed main, an +input table sized to its rows, a repeater band, and a financial +statement — their blocks size them +(`blocks/statement.md` never splits). + +Nothing is said twice on a page: the lockup, eyebrow, title, and card +labels each carry their own words, and no two elements chart the same +measure at the same grain — the narrower one takes the next metric down. + +Never pad a small ask; ask only when two sources or two readings tie, else +pick the obvious reading and note it for the closing report. A color the ask +names is a row in `### Theme`, not a design decision. + +### 3 · Resolve data — before any program + +Programs cannot query. Resolve first, and pass in **only the literals a +program cannot derive** — a filter or control value, a color split, a date +bound. A measure, a rollup, or a grid of numbers is a source the program +reads, never rows you type: typed numbers go stale the day you write them, +and a long list of them corrupts quietly when one row is duplicated. + +- `search`, or `list_documents` (`recommendations`) when the ask names no + source — skip both when the user already gave the id → `describe` the + source(s) the build will use — usually one: exact ids, column names, types. + A data model needs two describes — the model for its member names, then the + element for its columns; its element id is for `query` only. `describe` + prints the warehouse identifier with the friendly label in a trailing + comment (`"ACTIVECOUNT" number -- "Activecount"`); `get_column_by_name` + takes the **friendly label**, never the identifier. +- `query` the columns whose values the program will hardcode or split + by — a filter or flag literal, a control default, a categorical color + split, a date grain — and only those: distinct values with a `LIMIT` + of ~25, a date column's min/max, a Ledger's `COUNT(*)`. A guessed literal renders an empty + chart. Three `FROM` paths, and the warehouse path errors: a `connection` + query reads `FROM "connection".""`, a `datamodel` query + `FROM "datamodel".""`, a `workbook` query + `FROM "workbook".""` — that last one addresses columns by + their **opaque ids**, so `describe` the element first; friendly labels do + not bind there. The SQL is canonicalized ANSI: no `COUNT_IF`, `SUM_IF`, + `IFF` or `QUALIFY` (`sigma.formula.count_if` exists in a program, the SQL + function does not) — write `SUM(CASE WHEN … THEN 1 ELSE 0 END)`. +- A workbook element is readable only while it sits on a **visible** page and + its call persisted, so the hidden data page's base never is. `Table + workbook. not found` on an id a program just printed is that, not a + wrong id. +- App: the input tables live on `describe`'s connection id (an intake app + with no source: ask the user for the connection); `query` the seed values + — distinct statuses and owners, the key's first 8 values, 6 rows of name, + amount, date — and for a linked table that `COUNT(*) = COUNT(DISTINCT key)`. + +### 4 · Build — one program + +Read every block file the shell, skeleton, and bands name — the `## Blocks` +table maps need to file — in **one batch**, sent in the same round as step 3's queries: the +reads don't depend on the results. Send the theme read in that round too — +one `build_workbook` call on the build's target, whose whole program is the +highlight read under `### Theme`, and the one call that opens without the token +block. It returns `success-no-edits` and **no workbook id** — that is the +read working, not failing, and it leaves the `"new"` slot below unspent. +Then write the whole workbook as a single program and submit it once +— except a linked-table app (plan grid, forecast grid, queue): three calls, +`blocks/input-table.md`, the third being step 5's. The program: the fixed +opening below, then each page top to bottom with the y-cursor +(`y = prev.y() + prev.height()`, no blank rows). Every call for the workbook +— a linked-table app's second, the step 5 call, a retry, a recovery — +opens with the same token block; a `NameError` on a token is that +omission. +Every handle stays live for the rest of the program, so nothing is +re-fetched and cross-page actions wire inline where the target already +exists. `target: {type:"new", name, description}` — the description is +never omitted: one line on what the workbook shows and who it is for. Or +`"existing"` with the user's id when they asked to extend a workbook they +already have. Only the first call +that **persists** may be `"new"`: every later call for that workbook — a +step 5 fix, a retry, a continuation — is `"existing"` with the id that call +returned, which a runtime error returns too. A second `"new"` after an id +comes back strands a duplicate. A result carrying `persisted: false` built +nothing and returned no id — the theme read, a parse or typecheck error — +so it spends no slot and the next call is still `"new"`; never invent an id +to satisfy the rule. + +The opening is fixed: every visible element builds on `base.to_source()`, +every control targets base columns, so one filter re-scopes everything. + +```python +# tokens — a base row, then a hue row (### Theme); every program opens with them +CANVAS = '#fafafa' +INK = '#0a0a0a' +BODY = '#525252' +MUTED = '#737373' +HAIRLINE = '#e5e5e5' +FILL = '#f5f5f5' +SERIES_GREY = '#d4d4d4' +PRIMARY = '#1447e6' +TINT = '#dbeafe' +EDGE = '#bedbff' +SERIES = '#155dfc' +SERIES_MID = '#51a2ff' +# theme: a named color swaps the five lines above from the hue table + +theme = sigma.workbook.theme() +theme.set_canvas_color(CANVAS) +theme.set_surface_color('#8c8c8c') # seeds Sigma's control chrome — never white, or every input loses its border +theme.set_text_color(INK) +theme.set_title_color(INK) +theme.set_highlight_color(PRIMARY) # carries the hue into modal footers, control selection, progress bars +theme.set_text_font('Inter') +theme.set_data_font('Inter') +theme.set_page_width('large') + +old_pages = sigma.workbook.list_pages() # a new workbook starts with one page +home = sigma.workbook.add_page('Overview') # add_page, not old_pages[0]: only a Page hosts a rail or header panel +scenarios = sigma.workbook.add_page('Scenarios') # one add_page per page in the shell or Apps table, each its own variable + +data_page = sigma.workbook.add_page('Data') +data_page.set_visibility(False) +base = data_page.add_table() +base.set_name('Orders base') +base.set_frame(1, 1, 24, 12) +src = base.to_source() +for p in old_pages: + p.delete() # last, once its replacements exist — a workbook cannot reach zero pages +y = 7 # body y: the shell table +``` + +- Sources — match the constructor to what step 3 returned: a `table` → + `.warehouse_table(inode_id)`; a data model → bind + `dm = sigma.data.source.data_model(inode_id)` and pick the member by name, + `dm.list_tables()` filtered on `t.name()` (`get_table` takes a different id + than step 3 printed, and rejects it); a **workbook** element → + `.element(element_id)`. Ids come from step 3, never guessed; a wrong one + raises "has no data to build on". Only sources at the **same grain** may + join — a coarser side fans out every aggregate taken from it, silently; + different grains stay separate bases unless the coarse side contributes + only attributes (labels). A finer side is aggregated to the coarse one's + grain in its own `add_table` **before** the join, never after. + Two sources that must combine, feeding one base table: + `sigma.data.source.join(sigma.data.source.SourceInput(a, a_grouping), sigma.data.source.SourceInput(b, b_grouping), 'left_outer', [(a.column(''), b.column(''))])` + — join types `'inner' 'left_outer' 'right_outer' 'full_outer'`. The second + argument pins that side to its level and a grouped side must pass it: + `to_source()` drops the grouping, so a `SourceInput(a)` over a rollup joins + at row grain and multiplies — silently, into millions of rows. Column + lookups on a join side take the **friendly label** too (`a.column('Sigma + User Email')`), same as `get_column_by_name`. +- Several bases: one `add_table` each into its own variable, stacked 12 + rows apart on the data page. A pre-filtered or pre-aggregated view several + elements share is a second table off `base.to_source()`, named for what it is. +- Inter is a standard family — if the font call still raises, + `print(theme.list_fonts())` and resubmit with the closest name (Geist, + then System UI); never drop the calls. +- Extending a workbook **the user already had**: the token block still opens + the program, but skip the `theme.*` calls unless they asked for the + quickstart look — never silently restyle their workbook. This is about + whose workbook it is, not the target word: a build of your own that reads + `"existing"` because the theme read or a failure came first still sets the + full theme. + +End it with the state the next two steps need, one line each and nothing +else: `page.id()|page.name()` per page, then +`host|el.id()|el.name()|el.frame()` per element — `host` is the page, +container, or panel variable it was added to, because step 5 re-fetches from +that host and from nowhere else. Explicit `print` calls, one per element: a +`for` over a list of handles of different classes fails the typecheck, which +unifies a list to one element type. Each popover, drawer, and modal's children +list under it. An overlay printing none never got filled — it opens blank; +add its content. A program that skips this dump leaves steps 5 and 6 with no +ids to work from. + +Before every submit that adds an element or sets a frame — the first call, a +linked-table app's later calls, a retry, a step 5 fix that moves anything — +write the program to a file and run `python3 scripts/check.py ` (or pipe +it, `… | python3 scripts/check.py -`). It parses the dialect and folds the +frames, so a typecheck error and an overlapping or overrunning frame cost a +local run instead of a round trip. Fix every `ERROR` and run it again; a `WARN` +is a judgment call. A fix touching only color, copy, or formats goes straight to +submit — the file is a second copy of the whole program, bought for a fold that +has no frame to catch. It reads the state dump above too — +`python3 scripts/check.py `. + +**Program rules** — what the checker enforces, and what breaks real builds +without it (a failed call: `## Failure handling`): + +- Not full Python: no `def`/`class`/`lambda`/`while`, no slices, no `%`, `**`, + or `//`, no adjacent string literals (use `+`), single-clause + comprehensions only. Builtins are just `print len any enumerate zip range + list reversed sorted min max str isinstance datetime RuntimeError` — aggregation is + `sigma.formula.sum`, never `sum()`. No f-strings or `.format` — either + collapses a `{{placeholder}}` to `{placeholder}` and it stops binding; + spans are built with `+` exactly as the blocks show. +- Handles are objects: `col.name()`, never `col["name"]`. A chart's columns + come from **that chart's own** `get_column_by_name`. `add_column` takes a + `Formula`, not a column — `add_column('Block', col)` fails the typecheck; + wrap it (`sigma.formula.text(col)`, `number`, `date` — not the `as_*` casts, + which reject a bare column too), and the same wrap is what lets one formula + nest inside another (`blocks/formulas.md`). A calc's name must + not collide with a source column — a collision doesn't error, it silently + becomes `Revenue (1)` while name lookups keep returning the original + column; always `'Total revenue'`, never `'Revenue'`. +- No in-program SQL (`sigma.data.sql`), no probes (`help()`, `dir()`, + throwaway elements) — a throwaway persists on the page unless you + `delete()` it. Input tables only as `blocks/input-table.md` shows them. + Block handle names are per block — rename on a second use in one program. +- Anything no block demonstrates — a method, an argument, a construct — is + read up before it is written (`## SDK docs`); a block using it is proof. + +### 5 · Look at every visible page + +``` +screenshot({workbookId, source: {type: "page", pageId, pixelWidth: 1400}}) +screenshot({workbookId, source: {type: "element", elementId, pixelWidth: 1400, pixelHeight: 900}}) +``` + +`workbookId` is the id the build returned and is required; the size goes +**inside** `source`. A page shot captures the whole scrollable page, so +nothing is missed by being far down — but an element that scrolls inside +itself (a table, a pivot, an input grid) shows only its visible rows there. +An **element** shot sizes to that element's full content, so it is how you +check whether a grid really shows the rows it claims; it takes both `pixel*` +arguments. + +One page shot per **visible** page — never the hidden data page — after the +build returns, every page's call in one round; a deadline-exceeded error is +transient — retry once. Then an element shot for each table, pivot, or input +grid whose rows matter. + +Walk each page top to bottom and **list every element with a verdict** — +`el.name()`: what you see. A walk that reports prose, or "looks fine", is the +walk that ships the defects below: + +| Symptom | Fix | +|---|---| +| Text sliced at an edge (KPI line, last table row, axis label) | grow that row span — and the container's if it fills it | +| A surface far taller than its content | shrink it | +| Content flush to the edge of the fill behind it — sparkline, value, label | that element is its own surface: drop its `set_padding(False)` | +| A card taller than its frame, or the band under it pushed down | its last child overran the inner grid — pull that child up a row | +| A right-side button or trigger floating short of the edge | it is centered in its frame by default — `set_align('right')` | +| A shell button riding high beside a control, or touching it | the control's label owns row 1 — frame the button `(x, 2, w, 1)` and give the container `set_spacing('small')` | +| Error string, no-data label, blank chart | wrong literal or control default — re-`query`, fix the value | +| Labels ending in `…` or eating the plot | widen the element or shorten the label | +| `<` `>` `&` on the page, spans and `

` tags showing as text — headings and `**bold**` still fine | the markup was HTML-escaped on its way into the tool argument, so the program you checked and the program that ran differ. Resubmit those text elements, and diff what you send against the file `scripts/check.py` read | +| A table truncating headers or scrolling sideways | already 24 wide, so widening is not available: cut to the columns the reader decides on — the key, the editables, the two numbers that justify them — and move the rest to row-click detail (`blocks/detail.md`) | +| A submit or primary CTA below row ~28 | move that band above it — the reader never scrolls to act, though reading below it is fine | +| A page holding only a title and a divider | its bands were lost to a runtime error — rebuild them, and never leave copy promising a surface that isn't there | +| Text unreadable on a fill (the loud band) | restate that element's colors for the fill per the block's notes | +| A band missing, misplaced, or off-scope | rebuild it against the bands you composed | +| The same thing twice — a string repeated from the lockup, eyebrow, or the title above it; one measure charted in two elements | re-point the narrower one: its own words, or the next metric down | +| Input table empty, or a form missing a field | seed rows or the form were built before a column — follow `blocks/input-table.md`'s order | +| Sidebar absent from the shot | a page shot omits the **sidebar** only — not a defect (`print(side.list_pages())`). The header panel does render, so walk it like a band | + +Nothing found → skip the fix call and report (a plan, forecast, or queue app still +makes it, carrying the linked table's seed rows). Otherwise fix everything +found, across every page, in one further call — it re-fetches what it +touches, **from the host the step 4 dump named beside it**: `get_element` +sees a host's own children and no deeper, so a card's child comes off that +container, a strip's button off `page.header()`, and an overlay off +`sigma.workbook.get_page()` — an overlay is not an element. A +miss raises rather than returning `None`, so a fallback written after one +never runs. `list_pages` and `get_page` both hand back `PageBase`, which has +no panel methods: narrow with `isinstance(p, Page)` before `set_sidebar` or +`set_header` on a page you did not `add_page` yourself — the one case is +extending a workbook the user already had. The panel is a second narrow, not +the same one: `page.header()` and `page.sidebar()` return `Header | None`, so +`get_element` on either needs its own `isinstance` after the page's. A re-fetched element is base `Element`, narrowed with +`isinstance(el, )` — `LevelTable`, `Repeater`, `Container`, +`Button`, `KPI`, and the classes the blocks narrow to; a class named only in +the SDK docs may not be bound (`unknown class` is that) — one class, on a **bare variable** +(bind first; an inlined call narrows nothing). A height change moves every band +below it — re-run the y-cursor over them in the same call. If that fix +changed any height or y, screenshot those pages once more and repair what +it displaced; a fix that only touched color, copy, or formats needs no +re-check. Then stop — report fixes as attempted and name anything you +left unfixed. + +### 6 · Report + +Workbook id + URL, pages with their bands, the choices you made (chart +kinds, groupings, controls and defaults) so the user can redirect, for an +app the writeback connection, who its input tables accept edits from, and +the seed rows added, for a record detail the one UI step that turns its +picker into a row click (open that `select_row` effect, set its value to +Column), the one assumption +from step 2, and anything step 5 left open. Nothing else — +no next-steps filler. + +## Grid and size by type + +- A page body is **24 columns** wide, unlimited rows, origin (1,1); a row + is a fixed **24px pitch** at any page width — only column width scales. + `set_frame(x, y, width, height)` — four integers, **no auto height**. + Same-level frames must never overlap: an overlapping frame silently + relocates the elements **already placed**, pushing them far down the + page, not the one you just set. + Reading below row ~28 is fine — a laptop shows about that much, and + nothing reflows. +- A container's inner grid has **exactly as many rows as its outer frame + spans** (children address y = 1..H), with density `'high'` (24 columns + of the container's own width) set before framing children. Size a card + to its content and **leave no trailing empty row** — a reserved row + renders as a visible gap, and a child on the last row neither clips nor + grows the card. + `set_spacing('small')` is a 6px gap and padding ('medium' 8px is the + default, 'large' 12px), so the ~12px it costs vertically is the gutter + between cards, not a row to buy. A child placed *past* the last row does + grow the container and pushes the bands below it down. Overlay pages and + repeater cards are **12-column** grids. +- A popover trigger is a button and takes `set_align` like one; navigation + has its own call, `set_alignment('right')`. + +Minimums are floors — below them an element clips its label or value (the renderer itself allows 1×1); the quickstart column is what the blocks use. + +| Element | Min W×H | Quickstart | +|---|---|---| +| Chart | 4×6 | body per the section-card rule — in a split band the **card** narrows (7–17 page columns), the chart stays 24 inner columns | +| Map | 6×8 | `blocks/chart-map.md` owns the frames | +| KPI | 2×3 | 5–8 wide × 5 · 10 (sparkline) | +| Table | 4×3 | 24 wide. A long list scrolls and takes its band height (the Ledger's 18). A table whose rows must all be **seen** — a summary — sizes to them: with `set_summary_bar(False)` a data row is 32px against the 24px pitch, so `(4 × rows + 5) / 3` rounded up — 5 rows fit at 9, 13 at 19, and one row short clips the last. A collapsed group row measures the same as a data row. Over ~10 visible columns the first column truncates: cut columns, don't widen | +| Pivot | 4×3 | A pivot row is 2 grid rows, and there is no `set_summary_bar`: it keeps its title, its two header rows, and its grand-total row, so `2 × rows + 7` — five rows fit at 17. Drop 2 with no column dimension, and 2 more under `show_grand_totals=False` | +| Text | 1×1 | 1 row per ~24px of type: `p-*` 1 row, `###` 1–2, `##` 2, `#` 3 | +| Button | 2×1 | 4–6 × 2. Centered in its frame, not filled — so a right-hand frame alone still floats short of the edge: `set_align('right')` makes it flush, `'stretch'` fills the frame (a card row or paired footer, never a shell) | +| Control | 2×2 | 5–7 × 2 — narrower or 1 row high and the label and value clip | +| Navigation | 2×2 | 7 × 2 in a header; a vertical rail nav 2.5 rows per page (`blocks/sidebar.md`) | +| Divider | 1×1 | 24 × 1 | +| Container | 1×2 | exactly its content — last child ends on the last row, last column | +| Repeater | 12×9 | `blocks/cards.md` owns the frames | +| Input table · Form | 6×4 · 6×8 | the app blocks own the frames | +| Chat | 3×4 | `blocks/chat.md` owns the frames | + +## Design system — fixed + +### Theme — two lookups, never a derivation + +Twelve tokens open every program (the fixed opening above): seven from a +**base** row, five from a **hue** row. Blocks name tokens, never hex. + +| Token | Role | +|---|---| +| `CANVAS` | theme canvas | +| `INK` | headlines, card labels, KPI values, lockup | +| `BODY` | body copy, data labels, neutral-button text | +| `MUTED` | sublines, KPI titles, table headers, nav, quiet buttons | +| `HAIRLINE` | card, shell, and divider lines; settled-period shading | +| `FILL` | neutral button fill, statement subtotal band | +| `SERIES_GREY` | comparison / plan series | +| `PRIMARY` | hero and focus-tile fill, solid buttons, selected nav, theme highlight; eyebrow, chip, delta, and active-step text | +| `TINT` · `EDGE` | chip, soft button, callout, forecast-band fill · their border, the palest ramp dot, muted text on a `PRIMARY` surface | +| `SERIES` · `SERIES_MID` | single chart series, sparkline, heatmap dark end, named donut slice · the ramp's middle step | + +Literals that never change: `'#ffffff'` (card and shell fill, text on +`PRIMARY`), and the semantic pairs — text on fill — good `#008236` on +`#dcfce7`, bad `#c10007` on `#ffe2e2`, warn `#bb4d00` on `#fef3c6`. Warn is +the not-yet-bad state — pending, overdue, at risk; bad is failed, lost, +rejected. + +| Base | CANVAS | INK | BODY | MUTED | HAIRLINE | FILL | SERIES_GREY | +|---|---|---|---|---|---|---|---| +| Neutral | `#fafafa` | `#0a0a0a` | `#525252` | `#737373` | `#e5e5e5` | `#f5f5f5` | `#d4d4d4` | +| Stone | `#fafaf9` | `#0c0a09` | `#57534d` | `#79716b` | `#e7e5e4` | `#f5f5f4` | `#d6d3d1` | +| Zinc | `#fafafa` | `#09090b` | `#52525c` | `#71717b` | `#e4e4e7` | `#f4f4f5` | `#d4d4d8` | +| Slate | `#f8fafc` | `#020618` | `#45556c` | `#62748e` | `#e2e8f0` | `#f1f5f9` | `#cad5e2` | + +| Hue | PRIMARY | TINT | EDGE | SERIES | SERIES_MID | +|---|---|---|---|---|---| +| Neutral | `#171717` | `#e5e5e5` | `#d4d4d4` | `#525252` | `#a1a1a1` | +| Blue | `#1447e6` | `#dbeafe` | `#bedbff` | `#155dfc` | `#51a2ff` | +| Indigo | `#432dd7` | `#e0e7ff` | `#c6d2ff` | `#4f39f6` | `#7c86ff` | +| Violet | `#7008e7` | `#ede9fe` | `#ddd6ff` | `#7f22fe` | `#a684ff` | +| Emerald | `#007a55` | `#d0fae5` | `#a4f4cf` | `#009966` | `#00d492` | +| Teal | `#00786f` | `#cbfbf1` | `#96f7e4` | `#009689` | `#00d5be` | +| Green | `#008236` | `#dcfce7` | `#b9f8cf` | `#00a63e` | `#05df72` | +| Rose | `#c70036` | `#ffe4e6` | `#ffccd3` | `#ec003f` | `#ff637e` | +| Orange | `#ca3500` | `#ffedd4` | `#ffd6a7` | `#f54900` | `#ff8904` | +| Amber | `#bb4d00` | `#fef3c6` | `#fee685` | `#e17100` | `#ffb900` | + +- `python3 scripts/theme.py` prints the twelve lines and the `theme.*` calls + already resolved — pass whichever of `--highlight ` (the read below), + `--color `, `--base `, `--brand ` the build has. It matches + a hex on hue angle, which the tables cannot. The four rules under it are the + same lookup by hand. +- The highlight the workbook renders with names the hue. Read it before the + tokens are written: + ```python + theme = sigma.workbook.theme() + print(theme.highlight_color()) + ``` + The hue row nearest that hex replaces the five hue lines. Borrow the hue + only — the base stays Neutral, and never the theme's canvas, fonts, or + dark mode. +- A failed read → Neutral / Blue, already inline in the opening. +- A color word ("emerald", "blue accents") outranks the read → that hue row + replaces the five hue lines; a neutral word ("warmer grays", "slate") → + that base row replaces the seven. Rows only — never compute a tint or a ramp. +- A brand hex → `PRIMARY` = that hex; `TINT EDGE SERIES SERIES_MID` from the + hue row nearest by name. A separate chart color → only `SERIES` / + `SERIES_MID` from that row. +- A theme is fixed at build time; changing it later is a fix call that + re-sets `theme.*` and every element carrying `PRIMARY`, `TINT`, or `EDGE` + — name it in the report rather than attempt it silently. + +### Surfaces — every container is exactly one of four + +- **Card**: the white rounded card of the section-card code below — every + chart, table, pivot, form, and value list sits in one, and each drops the + fill the theme would otherwise give it: `set_background_color(None)`, plus + `'#ffffff'` on a table's slots — `header_style` and `cell_style`, and a + pivot's `column_header_style` and `row_header_style` too +- **Callout**: the card calls, but `TINT` fill and `EDGE` border +- **Loud band**: `PRIMARY` fill, `round` radius, no border line — the page's + one loud content surface (hero, or the focus KPI tile). Children restate + their colors: `'#ffffff'` for the statement and value, `EDGE` for the + support line; at most one per page. Solid buttons and loud bands are + `PRIMARY`; text is `INK` +- **Structural**: `set_padding(False)` + `set_gap(False)` + + `set_background_color(None)`, no border — grouping only: every title + block (`blocks/title.md`). `set_gap(False)` is for lines meant to touch, + so the KPI band and the shell's `hc` take `set_spacing('small')` and keep + theirs (`blocks/kpi-row.md`, `blocks/header.md`) — without it the + header's control and the button beside it collide + +The shell (header, sidebar) is not a band: white, a `HAIRLINE` line, an +`INK` lockup naming what the workbook is, `MUTED` nav with the selected +page in `PRIMARY` (`blocks/header.md`, `blocks/sidebar.md`) — the hue shows +on the selection, never as a fill. + +The **section-card rule** — label, optional muted subline, then the body: + +```python +sec = page.add_container() # page = whichever page handle (home, scenarios, …) +sec.set_frame(1, y, 24, 14) +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +sl = sec.add_text('**

**') # 16px bold ink — the card title +sl.set_frame(1, 1, 12, 1) +ss = sec.add_text('

') +ss.set_frame(1, 2, 18, 1) +# body (1, 3, 24, H-2); no subline → (1, 2, 24, H-1); cards under 9 rows drop the subline +``` + +- The label row's right side (columns 13–24) takes a chip, a segmented + control, or the tab-switch buttons (`blocks/tabs.md`). +- Never nest a decorative card inside another card — inner containers are + structural. + +### Text — the whole type system + +Text content is markdown. These are the only sizes that exist; spans set +**color only**, always a token in the one shape the blocks use — +`'…'` — never size a run with +`font-size`. Spans survive inside headings and around `**bold**` and +`{{placeholders}}`. + +| Role | Markup | Renders | +|---|---|---| +| Hero statement (the one loud line) | `### $4.2M to close the year` | 24px, white on `PRIMARY` | +| Page title | `### Plan workspace` | 24px, ink | +| Section statement | `

**Where the quarter stands**

` | 20px | +| Card label / lockup | `**Revenue by month**` — plain bold, sentence case | 16px, ink | +| Body | `

…

`, values bolded: `**{{Total revenue}}**` | 14px | +| Subhead / card subline | `'

One line saying the job.

'` | 14px | +| Eyebrow | `'

FY26 OPERATING VIEW

'` — the one CAPS line | 14px | +| Right-aligned | add `style="text-align: right"` to the `

` | | + +Fourteen is the floor for text elements; a smaller label is an element's own +title via `set_font_size`. KPI values are 24 bold, 30 on a loud band. + +Dynamic values: `add_text(md, formulas={'Total revenue': +sigma.formula.sum(col)})` binds `{{Total revenue}}`; use the base table's +columns. A placeholder takes **no format spec** — `{{Rev | $,.0f}}` is not +parsed, it renders that literal string on the page — and a bare aggregate +renders raw (`287243281.43`, `1.0366818247400837`). So **pre-format every +value bound into prose** (`blocks/formulas.md`, "Pre-format for prose"), +and only into prose: elements format their own columns with `set_format` +instead. + +**Color values, never words** — a number may take `PRIMARY`, good, +or bad; a sentence stays `INK`, `BODY`, or `MUTED`. Pages open with language, +not metrics — an imperative or noun phrase plus a subhead naming the job; +"Dashboard" is not a title. + +### Charts + +Element titles hidden — the card label does that job. Single measure = +`SERIES` — good `'#008236'` or bad `'#c10007'` instead when the +measure is itself a gain or a loss (retention, uptime / churn, attrition); +a comparison series = `SERIES_GREY`; a categorical split +only when the categories are the subject (≤6). Every date axis truncated +to a grain and formatted; x labels never rotated. A rate, ratio, or +threshold measure never forces a zero baseline. Every KPI carries context +(`blocks/kpi-row.md`). + +The Dashboards row names each band's chart; when that chart does not fit +the band's data, take the row the data matches: + +| The band's data | Chart | +|---|---| +| one measure over a date, 12–40 marks at a grain | line (`blocks/chart-trend.md`; a plan or prior column → two-series) | +| a measure by an entity, 4–25 values | ranked horizontal bar, top-10 (`blocks/chart-ranked.md`) — never a sorted table | +| two measures by a category ≤8 | grouped bars (`blocks/chart-compare.md`) | +| a measure over a date split by a category ≤6 | stacked bars (`blocks/chart-composition.md`) | +| a static split, ≤6 slices, no date on the band | donut (`blocks/chart-composition.md`) | +| tiers as separate measure columns | ribbon (`blocks/chart-composition.md`) | +| a measure by two categoricals — ≤6 across, ≤12 down | heatmap (`blocks/pivot.md` `color_scale`), 14+ columns wide; hours as columns need all 24, so bucket to dayparts or take the wider frame | +| an opening figure, signed steps, a closing figure — a bridge, variance walk, build-up | waterfall (`blocks/chart-waterfall.md`) | +| a measure by a lat/long pair, or by a named region — where, footprint, estate, by site | point or region map (`blocks/chart-map.md`) | + +`set_series_color`, `set_category_color`, `set_value_color`, and +`color_scale` take `SERIES`, `SERIES_MID`, `SERIES_GREY`, or the ramp +`TINT → SERIES_MID → SERIES` (good/bad for a gain-or-loss measure, +`'#ffffff'` on a `PRIMARY` tile) — never `PRIMARY` or `INK` (the forecast +block's actual bars are the one `INK` series, and say so), and never +`theme.highlight_color()`; every chart block sets its own colors. + +## Blocks + +Blocks are plain SDK code — change columns, copy, spans, and counts +freely; the styling calls stay as written, token names included. + +| Need | File | +|---|---| +| Header band, or shared header + nav, with the Assistant trigger | `blocks/header.md` | +| Sidebar rail — the app shell: nav, picker, Assistant, status | `blocks/sidebar.md` | +| Title block + chip + breadcrumb + progress rail | `blocks/title.md` | +| KPI row + variants | `blocks/kpi-row.md` | +| Insight callout | `blocks/insight.md` | +| AI-written insight (explicit ask only; read with `blocks/insight.md`) | `blocks/insight-ai.md` | +| Active-context strip (page follows one selection) | `blocks/context-strip.md` | +| Comparison strip (baseline · current · change) | `blocks/compare-strip.md` | +| Hero band | `blocks/hero.md` | +| Trend over time | `blocks/chart-trend.md` | +| Actuals vs projection (forecast band) | `blocks/chart-forecast.md` | +| Category comparison | `blocks/chart-compare.md` | +| Ranking (top-N) | `blocks/chart-ranked.md` | +| Composition (stacked · donut) | `blocks/chart-composition.md` | +| Waterfall — a bridge from an opening figure to a closing one | `blocks/chart-waterfall.md` | +| Map — points from coordinates · shaded named regions | `blocks/chart-map.md` | +| Detail table + conditional formats · goals card (targets, quotas, budgets with a bar) | `blocks/table.md` | +| Pivot (measure × two dimensions) | `blocks/pivot.md` | +| Financial statement — P&L: sections, computed subtotal lines, variance | `blocks/statement.md` | +| Filter toolbar · full-height rail, form, or record column | `blocks/filters.md` | +| Buttons + action wiring | `blocks/buttons.md` | +| Tab switcher on a card, or a tabbed main surface | `blocks/tabs.md` | +| Stepped wizard in a modal · one-step form modal | `blocks/wizard.md` | +| Record detail — a picker into a modal, drawer, or popover · record card beside a list | `blocks/detail.md` | +| Assistant chat — a popover from the shell's top-right trigger | `blocks/chat.md` | +| Repeated cards — one design per row or group (rep, region, product, queue item) · list rows · card feed with a per-card action | `blocks/cards.md` | +| A calculation no block shows — average, conditional, share of total, prior period, rank, date window, pre-formatting for prose | `blocks/formulas.md` | +| Input table — empty or linked, columns, seed rows; read with any app block | `blocks/input-table.md` | +| Plan grid app — with `input-table` `sidebar` `title` `formulas`; then `hero` `kpi-row` `cards` `context-strip` `compare-strip` `buttons` `chat` as its bands call for them | `blocks/plan-grid.md` | +| Forecast grid app — date spine × entities; the plan-grid reads plus `plan-grid` `chart-trend` | `blocks/forecast.md` | +| Review queue app — with `input-table` `sidebar` `title` `formulas`; then `filters` `kpi-row` `buttons` `chat` | `blocks/queue.md` | +| Intake app — with `input-table` `sidebar` `title` `formulas` `wizard`; then `kpi-row` `chart-trend` `chart-composition` `table` `buttons` `chat` | `blocks/intake.md` | + +## SDK docs — `read_resource` + +For a need no block covers (for calculations, `blocks/formulas.md` comes +first) — and only then: the `build_workbook` description's README, module, +and connector-skill reads do not apply here. Compose a `` URI from +the scheme below — module names are the feature: `barChart`, `levelTable`, +`inputTable`, `form`, `data/source`, `action/inputTableEffects`. The +`skills/` names are a **closed set**, listed in the row below and nowhere +else: a block is a block, never `sdk://skills//SKILL.md`. If a URI +errors, `list_resources` once and take the name from what it lists. + +| Read | For | +|---|---| +| `sdk://sdk/reference/sigma/.md` | every method on a class, typed signature, `Literal` variants — the read for any call the blocks do not show, and enough on its own | +| `sdk://skills//SKILL.md` | a feature no block covers, and only these five — `forms`, `survey`, `relationship-columns`, `balance-sheet`, `maps` (region-type edge cases only — `blocks/chart-map.md` has the rest) | + +Take only mechanics from those skills — their theme, styling, and layout +advice loses to the design system above. Their `/sdk/...` and `/skills/...` +cross-references need the `sdk://` prefix; no path, URI, or tool name ever +goes inside a program. + +## Failure handling + +- **Typecheck error** — nothing ran. Fix exactly what the error names and + resubmit; do not restructure a working block around it. +- **Runtime error** — the edits before it are committed. Recover in one + program (it opens with the token block too) against the returned id: + bind `old = list_pages()` **first**, + `add_page` the pages afresh, then `delete()` each page in `old` — a + workbook cannot reach zero pages, so deleting before adding dies on the + last one. A later call of a linked-table app keeps every page — its base + must survive: resubmit that call alone, fixed, after `delete()`-ing what + its failed run had built before the error. +- **A rejected approach** — a runtime refusal naming the operation, not a + type or syntax problem — one retry, then change approach or tell the + user; never resubmit just to see. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md new file mode 100644 index 0000000..667ada3 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/buttons.md @@ -0,0 +1,68 @@ +# Buttons + +Three appearances, used beside the page title or under a card's content. + +```python +go = page.add_button('Review plan →') +go.set_frame(19, 4, 6, 2) +go.set_align('right') # page actions end flush with the content's right edge +go.set_appearance('solid') # primary: PRIMARY, round +go.set_shape('round') +go.set_fill_color(PRIMARY) +go.set_font_color('#ffffff') + +soft = page.add_button('+ New view') # secondary: tint +soft.set_frame(14, 4, 5, 2) +soft.set_align('right') +soft.set_appearance('solid') +soft.set_shape('round') +soft.set_fill_color(TINT) +soft.set_font_color(PRIMARY) + +quiet = page.add_button('Back to overview') # tertiary: text only +quiet.set_frame(1, 1, 5, 2) # wherever it sits — frames never overlap +quiet.set_appearance('text') +quiet.set_font_color(MUTED) +quiet.set_bold(False) +``` + +Wire clicks with effects; `on_click` returns a sequence you can toast: + +```python +target_page = sigma.workbook.get_page('') # not the current page's id +seq = go.on_click('open review', [sigma.action.navigateEffect.navigate_to(target_page)]) +# also: overlayEffects.open_overlay(drawer) / close_overlay(), +# selectTabEffect.select_tab(tab), openLinkEffect.open_link(url) +``` + +Decision panel — primary, soft, and a neutral third side by side in one +card row (`sec`), frames 8 wide at x `1/9/17`: the primary and soft take +the same calls as above plus `set_button_size('small')` and +`set_align('stretch')` (fill the 8-column frame); the neutral: + +```python +changes = sec.add_button('Request changes') # neutral: grey fill +changes.set_frame(17, 2, 8, 2) +changes.set_appearance('solid') +changes.set_shape('round') +changes.set_button_size('small') +changes.set_align('stretch') +changes.set_fill_color(FILL) +changes.set_font_color(BODY) +``` + +- A page-title action sits at columns 14–24 — rows 4–5 under an in-page + band, rows 2–3 when the title rows are 1–4 (the title block shifts up; + stay clear of its full-width rule). +- Confirm a click: `seq.set_success_toast(title='Opening review')` (optional `message=`, `{{name}}` via `formulas=`). +- A row click can branch on the clicked row's value: + `tbl.on_select('open', [sigma.action.controlFlow.if_else([(sigma.action.condition.equals(tbl.get_column_by_name(''), 'Open'), [sigma.action.overlayEffects.open_overlay(drawer)])], [sigma.action.navigateEffect.navigate_to(done_page)])])` + — conditions `equals greater_than less_than at_least at_most between is_null text_match` on a column; the else list may be empty. +- An effect's target must already exist, so wire each action after the page + it points at — one program builds every page, so ordering is all it takes. + Name any flow you could not wire. +- Decision buttons that write rows are wired in `blocks/queue.md` (a + drawer) and `blocks/plan-grid.md` (a page button): effects run write → + `refresh_element` → `close_overlay`, and the toast is set on the returned + sequence. A status chip is a formula-labelled button with no action + (`blocks/title.md`). diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md new file mode 100644 index 0000000..4ba38e1 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/cards.md @@ -0,0 +1,201 @@ +# Card list (repeater) + +One card per row or group — a catalog, a feed, a work queue, or **a summary +card per entity** (per rep, per region, per product). The default way to show +a modest set of records: under ~200 rows a card list reads better than a +table, and any band that would otherwise be the same card design built three +or more times is one. Cards hold text, buttons, dividers, and plain +containers — no table, chart, KPI, control, tabbed container, nested +repeater, or **value list** (the SDK binds a card's value list to the whole +source, not to the card, whatever `layoutHost.py` says). The card's own grid +is a **fixed 12 columns**; every value on it is a text bound to a column of +the repeater's source. + +One card per entity starts with a **one-row-per-entity table on the data +page**: grouped, with its facts as grouping calculations, text-typed and +pre-formatted (`blocks/formulas.md`, "Pre-format for prose"): + +```python +lst = data_page.add_table(src) +lst.set_name(' list') +lst.set_frame(1, 13, 24, 8) +lg = lst.set_groupings([lst.get_column_by_name('')])[0] +lg.set_calculation('Value text', sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.sum(lst.get_column_by_name('Revenue')), sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M')))) +lg.set_calculation('Meta', sigma.formula.ampersand(sigma.formula.text(sigma.formula.count_distinct(lst.get_column_by_name(''))), sigma.formula.const_text(' ORDERS'))) +lg.set_calculation('Kind', sigma.formula.max(lst.get_column_by_name(''))) +key = lg.keys()[0] +``` + +Then the repeater, on the page or inside a section card: + +```python +cards = sec.add_repeater(lst.to_source()) +cards.set_frame(1, 3, 24, 23) # three rows of cards; 15 for two, 30 for four +cards.set_grouping(lg) # one card per group — omit over a row-grain source +cards.set_card_layout('grid') +cards.set_card_size('medium') +cards.set_scroll_type('vertical') # the frame is fixed; the rest scrolls +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_background_color(None) # the BOX the cards sit in +cards.set_padding(False) # ...and its padding +cards.set_spacing('small') # ...and the gap between cards +cards.set_card_padding(True) # the CARDS' own padding — required +cards.set_card_spacing('small') # element gap stays on — off, every card locks to a 5-row box +cn = cards.add_text('**{{Name}}**', formulas={'Name': key}) +cn.set_frame(1, 1, 12, 1) +cm = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +cm.set_frame(1, 2, 12, 1) +rule = cards.add_divider() +rule.set_frame(1, 3, 12, 1) +rule.set_color(HAIRLINE) +ce = cards.add_text('

Revenue

') +ce.set_frame(1, 4, 12, 1) +cv = cards.add_text('**{{Value}}**', formulas={'Value': lst.get_column_by_name('Value text')}) +cv.set_frame(1, 5, 12, 1) # the last row of the 5-row card +``` + +- A card is a **5-row box** (30px rows) whatever its template — fewer rows + leave dead space under the content, a sixth grows it — so every template + ends on row 5. Frames: on `'grid'` 15 rows show two rows of cards, 23 + three, 30 four; on `'list'` 20 rows show three cards, +7 per extra card. + Never `set_element_gap(False)` on a repeater. +- A repeater over a filtered queue opens empty sooner or later — give it + `cards.set_no_data_label('NO PENDING ITEMS')` rather than a blank band. +- Filter through the **source table** (a control targeting `lst`). Sorting + has **no SDK call yet** and the table's own sort does not reach the cards + — they come in query order. Cards take no conditional formats or + theme-token card colors (hex only). +- **Two styling surfaces, and mixing them is the classic bug**: the inherited + `Element` methods style the **box the cards sit in**, `set_card_*` styles + each card — without `set_card_padding(True)` every card renders flush to + its edges. +- On `'grid'`, card size sets the width and so how many fit across — + `'small'` 150–250px · `'medium'` 250–400 (≈5 across a full-width + repeater) · `'large'` 400–600 (≈3); on `'list'` every card is a row. +- **A sentence per card is a column**, never prose written N times — a + grouping calculation, or for a real per-card narrative a guarded + `call_text('ai_complete', …)` calculation cleaned with `regexp_replace` + (`blocks/insight-ai.md` carries the guard). Hand-building (the + authored-copy exception in SKILL.md step 2) caps at three or four cards. +- A **chip** in a card is a button bound to a column — + `cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')})` + · solid · pill · small · `'#ffffff'` fill, `BODY` text on a `FILL` card. + Its fill cannot vary *by* the value. + +## On the page + +A Portfolio dashboard's cards sit on the page under a label, not in a +section card — the calls above, framed for two rows of five: + +```python +cl = page.add_text('****') +cl.set_frame(1, y, 24, 1) +cards = page.add_repeater(src) +cards.set_frame(1, y + 1, 24, 9) # one row of five; 15 for two rows — band 10 or 16 +cards.set_scroll_type('vertical') # the rest scroll +``` + +## List rows + +A contained list — holdings, accounts, deals, queue items — as **one card +16 columns wide**: search and a segmented filter on the card's first two +rows, then one `FILL` row per record with a key tile, the name over a muted +meta line, a chip, and the value flush right under a `VALUE` eyebrow. +`lst` and `lg` as above, plus `Tile` = +`sigma.formula.upper(sigma.formula.left(key, sigma.formula.const_num(3)))`. + +```python +sec = page.add_container() # the section card, no label — the search is the header +sec.set_frame(1, y, 16, 29) # two control rows + the repeater's +sec.set_column_density('high') +sec.set_spacing('small') +sec.set_background_color('#ffffff') +sec.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +find = sigma.control.add_text_control(sec, target=lst.get_column_by_name(''), match_mode='contains', case_sensitive=False, placeholder=' name') +find.set_name('Search') +find.set_frame(1, 1, 12, 2) +seg = sigma.control.add_segmented_control(sec, options=['', '', ''], target=lst.get_column_by_name('')) # up to 4 options at this width — more collapses to a dropdown; use a list control instead +seg.set_name('') +seg.set_frame(14, 1, 11, 2) +cards = sec.add_repeater(lst.to_source()) # the repeater calls above, with: +cards.set_frame(1, 3, 24, 27) # four cards; 20 for three, +7 per extra card +cards.set_card_layout('list') +cards.set_card_background_color(FILL) +cards.set_card_border(None) +tile = cards.add_text('

**{{Tile}}**

', formulas={'Tile': lst.get_column_by_name('Tile')}) +tile.set_frame(1, 2, 1, 3) # rows 2–4: the row's content sits centered in the 5-row card +tile.set_vertical_align('middle') +tile.set_background_color('#ffffff') +tile.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(2, 2, 5, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(2, 3, 5, 1) +mt.set_vertical_align('top') +chip = cards.add_button('{{Kind}}', formulas={'Kind': lst.get_column_by_name('Kind')}) +chip.set_frame(7, 2, 3, 2) # 3 columns — a real category name truncates in 2 +chip.set_appearance('solid') +chip.set_shape('pill') +chip.set_button_size('small') +chip.set_fill_color('#ffffff') +chip.set_font_color(BODY) +ve = cards.add_text('

VALUE

') +ve.set_frame(10, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(10, 3, 3, 1) +vv.set_vertical_align('top') +``` + +The controls target `lst`, so the list filters without re-scoping the +page; a control's label always renders — name it for the reader, never +`''`. + +## Card feed + +The list card at page width, each record carrying its own action — deals to +forecast, items to review, stores to open. No tile and no search card, so the +card re-frames across its 12 columns — the repeater calls above, with: + +```python +cards = page.add_repeater(lst.to_source()) +cards.set_frame(1, y, 24, 22) # three cards; 20 without the third line +cards.set_card_layout('list') +cards.set_card_background_color('#ffffff') +cards.set_card_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +cards.set_no_data_label('NO MATCH') +nm = cards.add_text('**{{Name}}**', formulas={'Name': key}) +nm.set_frame(1, 2, 6, 1) +nm.set_vertical_align('bottom') +mt = cards.add_text('

{{Meta}}

', formulas={'Meta': lst.get_column_by_name('Meta')}) +mt.set_frame(1, 3, 6, 1) +mt.set_vertical_align('top') +sub = cards.add_text('

{{Kind}}

', formulas={'Kind': lst.get_column_by_name('Kind')}) +sub.set_frame(1, 4, 6, 1) # the third line — drop it and frame the repeater 20 +sub.set_vertical_align('top') +ve = cards.add_text('

VALUE

') +ve.set_frame(7, 2, 3, 1) +ve.set_vertical_align('bottom') +vv = cards.add_text('

**{{Value}}**

', formulas={'Value': lst.get_column_by_name('Value text')}) +vv.set_frame(7, 3, 3, 1) +vv.set_vertical_align('top') +go = cards.add_button('Review →') # soft (blocks/buttons.md) — unstyled renders Sigma blue +go.set_frame(10, 3, 3, 2) +go.set_align('right') +go.set_appearance('solid') +go.set_shape('round') +go.set_button_size('small') +go.set_fill_color(TINT) +go.set_font_color(PRIMARY) +go.on_click('open', [sigma.action.navigateEffect.navigate_to()]) +``` + +- A **third content line grows the card**: the list rule's 20 rows for three + cards becomes 22. The trailing row cannot be reclaimed. +- A card's button reaches a **page or an overlay, never a row** — an + effect's value binds to the whole source, so a card cannot hand its own + record to a picker or a detail panel (`blocks/detail.md`). +- The same repeater runs **inside a drawer or modal**, on a container in + it — `set_card_border(None)` on a `FILL` card. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md new file mode 100644 index 0000000..bfa20c2 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-compare.md @@ -0,0 +1,29 @@ +# Chart: category comparison (bars) + +Grouped bars answer "which categories differ, and by how much" — the default +two-measure shape for actual against plan, side by side so the gap shows per group. + +```python +cmp = sec.add_bar_chart(src) +cmp.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +cmp.set_dimension(cmp.get_column_by_name('')) +cmp.set_series([ + (sigma.formula.sum(cmp.get_column_by_name('Target')), 'Plan'), + (sigma.formula.sum(cmp.get_column_by_name('Revenue')), 'Actual'), +]) +series = cmp.list_series() +cmp.set_series_color(series[0], SERIES_GREY) +cmp.set_series_color(series[1], SERIES) +cmp.set_stacking('none') +cmp.dimension_axis().set_marks('none') +cmp.dimension_axis().set_label_angle(0) +cmp.value_axis().set_marks('none') +cmp.legend().set_position('top-left') +cmp.title_style().set_visible(False) +cmp.set_background_color(None) +cmp.set_padding(False) +``` + +- Single measure: one series in `SERIES` — good `'#008236'` or bad `'#c10007'` when the measure is itself a gain or a loss (retention / churn), `cmp.legend().set_visible(False)`. +- Long category labels: `cmp.set_orientation('horizontal')` — the dimension moves + to the y-axis and labels sit flat; never reach for a label angle instead. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md new file mode 100644 index 0000000..429887b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-composition.md @@ -0,0 +1,108 @@ +# Chart: composition + +How a total splits across categories, month by month. Earns its place only when +the categories are the subject and there are at most ~6 of them — verify the +count with `query` first; filter or group the tail. + +```python +mix = sec.add_bar_chart(src) +mix.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +mo = mix.add_column('Mix month', sigma.formula.date_trunc('month', mix.get_column_by_name(''))) +mo.set_format_pattern('%b %Y') +mix.set_dimension(mo) # columns never cross elements +mix.set_series([(sigma.formula.sum(mix.get_column_by_name('Amount')), 'Total amount')]) +mix.set_color_category(mix.get_column_by_name('')) +mix.set_category_color('', SERIES) # one per value, largest first: SERIES SERIES_MID SERIES_GREY EDGE TINT — the donut's rule below +mix.set_stacking('stacked') +mix.dimension_axis().set_marks('none') +mix.dimension_axis().set_label_angle(0) +mix.value_axis().set_marks('none') +mix.legend().set_position('right') +mix.title_style().set_visible(False) +mix.set_background_color(None) +mix.set_padding(False) +``` + +- Share-of-total: `mix.set_stacking('percent')`. + +Donut — static composition, the same ≤6 cap, on the user's ask or when the +Dashboards row or the Charts table names it: + +```python +d = sec.add_donut_chart(src) +d.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +d.set_category(d.get_column_by_name('')) +d.set_values([(sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total revenue')]) +d.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +d.set_hole_value((sigma.formula.sum(d.get_column_by_name('Revenue')), 'Total')) +d.get_column_by_name('Total').set_format('currency_si', decimals=1, currency='$') +d.hole().set_size(0.7) +d.legend().set_position('right') +d.title_style().set_visible(False) +d.set_background_color(None) +d.set_padding(False) +``` + +- `hole().set_size` takes 0.4–0.8; the hole shows `'Total'` — the figure the + slices are shares of. +- Slice colors are set per category, as every chart sets its own: + `d.set_category_color('', SERIES)` — one call per slice, + every slice named, largest first: `SERIES` `SERIES_MID` `SERIES_GREY` + `EDGE` `TINT`; none left to the workbook palette. + +## Ribbon — tiers that live in separate columns + +When the split is across **measures**, not a categorical column, drop the +dimension entirely: one horizontal bar at `percent` stacking reads as a +progress ribbon. This is the funnel/gauge shape — pipeline tiers, budget +committed/spent/remaining, capacity used/free, roles filled/offered/open. + +**Difference nested tiers first** or the stack double-counts — if Commit +already contains Closed won, stack the increments, not the raw tiers: + +```python +base.add_column('Commit (net)', sigma.formula.subtract( + sigma.formula.zn(base.get_column_by_name('Commit')), + sigma.formula.zn(base.get_column_by_name('Closed won')))) +# one per tier; the last is Total minus the tier before it + +rib = sec.add_bar_chart(src) +rib.set_frame(1, 3, 24, 6) # no set_dimension — that is the trick +rib.set_series([ # top of the stack first: set_series paints the LAST series at the bottom + (sigma.formula.sum(rib.get_column_by_name('Remaining pipe')), 'Pipeline'), + (sigma.formula.sum(rib.get_column_by_name('Commit (net)')), 'Commit'), + (sigma.formula.sum(rib.get_column_by_name('Closed won')), 'Closed'), +]) +rib.set_orientation('horizontal') +rib.set_stacking('percent') +t = rib.list_series() +rib.set_series_color(t[0], TINT) # one hue stepped up — a certainty +rib.set_series_color(t[1], SERIES_MID) # ramp, never unrelated colors +rib.set_series_color(t[2], SERIES) +dl = rib.data_labels() +dl.set_visible(True) +dl.set_as_percent(True) +dl.set_precision(0) +dl.set_color(BODY) +rib.dimension_axis().set_show_labels(False) +rib.dimension_axis().set_marks('none') +rib.value_axis().set_show_labels(False) +rib.value_axis().set_marks('none') +rib.legend().set_visible(False) +rib.title_style().set_visible(False) +rib.set_background_color(None) +rib.set_padding(False) +``` + +- **`series[0]` renders at the right end** of a horizontal percent stack, so + list the tiers least-certain first and the ramp reads dark→pale left to + right. Smoke-tested; getting this backwards inverts the whole chart. +- The bar carries percentages only, so give each tier a hand-built legend + cell under it at row 9 — `● Tier` in its own color, the value as + `

**…**

`, a muted `p-small` count — three or four across the card at width 6. Use + `EDGE` for the palest tier's **dot**: `TINT` reads as + nothing on white. +- Card height 9 — label 1, subline 2, ribbon 3–8, legend 9. Leave no + trailing row. +- One series alone is a **coverage meter** ("62% of quota"): same calls, + a single ratio series. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md new file mode 100644 index 0000000..93a9e06 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-forecast.md @@ -0,0 +1,51 @@ +# Chart: actuals vs projection + +History as solid bars, the projection as a `SERIES` line, and a tinted band +marking where the data stops being real. Use whenever the source carries a +flag separating actuals from forecast/plan/target rows. + +```python +fc = sec.add_combo_chart(src) +fc.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +fm = fc.add_column('Forecast month', sigma.formula.date_trunc('month', fc.get_column_by_name(''))) +fm.set_format_pattern('%b %Y') +fc.set_dimension(fm) +fc.dimension_axis().set_scale_type('time') +fc.dimension_axis().set_marks('none') +fc.dimension_axis().set_label_angle(0) +actual = sigma.formula.eq(fc.get_column_by_name(''), sigma.formula.const_text('')) +projected = sigma.formula.neq(fc.get_column_by_name(''), sigma.formula.const_text('')) +fc.set_series([ + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), actual), 'Actual'), + (sigma.formula.sum_if(fc.get_column_by_name('Revenue'), projected), 'Projected'), +]) +pair = fc.list_series() +pair[0].set_format('currency_si', decimals=1, currency='$') +pair[1].set_format('currency_si', decimals=1, currency='$') +fc.set_series_shape(pair[0], 'bar') # shapes before colors — reshaping +fc.set_series_shape(pair[1], 'line') # can clear formatting +fc.set_series_color(pair[0], INK) # ink bars — the one series that is not SERIES_* +fc.set_series_color(pair[1], SERIES) +fc.value_axis().set_marks('none') +fb = fc.add_ref_band( + 'dimension', + sigma.refMarkValue.formula(sigma.formula.min_if(fm, projected)), + sigma.refMarkValue.column(fm, 'max')) +fb.set_fill_color(TINT) +fb.set_line_visible(False) +fb.set_label_visible(True) +fb.set_label_text('FORECAST') +fb.set_label_color(MUTED) +fc.legend().set_position('top-left') +fc.title_style().set_visible(False) +fc.set_background_color(None) +fc.set_padding(False) +``` + +- Verify the flag column's distinct values with `query` first — the + `''` literal is the usual empty-chart culprit. +- Bars keep their zero baseline — never suppress zero on a bar series. +- A second measure (e.g. cost) rides along as two more `sum_if` series: + actual as a `SERIES_GREY` bar, projected as a `SERIES_GREY` line. +- The band spans first projected month → last month; on a chart with no + projected rows yet it collapses harmlessly to the right edge. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md new file mode 100644 index 0000000..e6d308b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-map.md @@ -0,0 +1,48 @@ +# Chart: map (points · regions) + +Where the measure sits geographically — the answer to "which stores", "which +metros", "is it one site or the whole area" that a ranked bar cannot give. +Two elements, chosen by how the data locates a mark: + +- `describe` shows a latitude **and** longitude pair → `add_point_map` +- it shows a named area (country, state, county, ZIP, province) → `add_region_map` + +```python +mp = sec.add_point_map(src) +mp.set_frame(1, 3, 24, 14) # H = 16; a shorter card → SKILL.md's section-card rule, 12 rows the floor — a cramped map blurs +mp.set_latitude(mp.get_column_by_name('')) +mp.set_longitude(mp.get_column_by_name('')) +mp.set_point_size((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Total revenue')) +mp.set_color_scale((sigma.formula.sum(mp.get_column_by_name('Revenue')), 'Shaded revenue')) +mp.set_label([mp.get_column_by_name('')]) +mp.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +mp.title_style().set_visible(False) +mp.set_background_color(None) +mp.set_padding(False) +``` + +A region map is the same shape with one different binding — the region column +and its type, which is required alongside it: + +```python +rm = sec.add_region_map(src) +rm.set_region(rm.get_column_by_name(''), 'us-state') +rm.set_color_scale((sigma.formula.sum(rm.get_column_by_name('Revenue')), 'Total revenue')) +``` + +- `region_type` is one of `'country'` `'us-state'` `'us-county'` + `'us-zipcode'` `'us-cbsa'` `'us-postal-place'` `'ca-province'`. It must + match what the column actually holds — `query` the distinct values first; + two-letter codes and full names are both accepted, mixed ones are not. +- `set_color_scale` binds the measure; the gradient takes the workbook palette. +- Lat/long are **grouping keys**, so rows sharing a coordinate collapse into + one mark and the measure aggregates across them — which is what you want + per site, and wrong per transaction. If each row must be its own mark, the + source needs a key that makes it unique. +- Size and color can carry the same measure — size for magnitude, shade for + the same thing read at a glance. Two *different* measures on one map is one + too many things to decode; put the second in the band beside it. +- A map answers "where", never "how much" — pair it with a ranking or a + table in the same band, or the reader can see the shape and not the number. +- Only `set_label` renders text on the marks; a point map has no title on + screen, so the card label does that job as usual. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md new file mode 100644 index 0000000..89c6c9d --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-ranked.md @@ -0,0 +1,37 @@ +# Chart: ranking (horizontal top-N) + +A ranking is a sorted horizontal bar — never a sorted table, never vertical +bars with rotated labels. + +```python +rank = sec.add_bar_chart(src) +rank.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +rank.set_dimension(rank.get_column_by_name('')) +rank.set_series([(sigma.formula.sum(rank.get_column_by_name('Revenue')), 'Total revenue')]) +top = rank.list_series()[0] +top.set_format('currency_si', decimals=1, currency='$') +top.set_filter(sigma.filter.top_n(10)) +rank.set_orientation('horizontal') +rank.set_sort_by_series('dimension', top, ascending=False) +rank.set_series_color(top, SERIES) +rank.dimension_axis().set_label_angle(0) +rank.dimension_axis().set_marks('none') +rank.value_axis().set_marks('none') +rank.value_axis().set_show_labels(False) # the labels on the bars carry the values +dl = rank.data_labels() +dl.set_visible(True) +dl.set_color(BODY) +rank.legend().set_visible(False) +rank.title_style().set_visible(False) +rank.set_background_color(None) +rank.set_padding(False) +``` + +- Adjust N in `sigma.filter.top_n(10)`; past ~12 bars give the card a taller + frame rather than a smaller font. +- The data labels sit outside the bars only while there is room: the longest + bar's label falls inside it, `BODY` on `SERIES`, which is unreadable. Give a + ranking a card of 14+ columns. Width is not enough when the values are close + — a ratio, a rate, a cost-per, anything whose smallest bar is over ~80% of + the largest puts every label inside — so for those, axis labels instead: + `rank.value_axis().set_show_labels(True)` and `dl.set_visible(False)`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md new file mode 100644 index 0000000..fb16d51 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-trend.md @@ -0,0 +1,66 @@ +# Chart: trend line + +The default shape for any measure over time: the time scale spaces marks by +real elapsed time, so gaps and pacing read true. The line row of SKILL.md's +chart table; the Dashboards row names the headline and window variants below. + +```python +tr = sec.add_line_chart(src) +tr.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +month = tr.add_column('Order month', sigma.formula.date_trunc('month', tr.get_column_by_name(''))) +month.set_format_pattern('%b %Y') +tr.set_dimension(month) +tr.dimension_axis().set_scale_type('time') +tr.dimension_axis().set_marks('none') +tr.dimension_axis().set_label_angle(0) +tr.set_series([(sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue')]) +ser = tr.list_series()[0] +ser.set_format('currency_si', decimals=1, currency='$') +tr.set_series_color(ser, SERIES) +tr.value_axis().set_marks('none') +vs = tr.value_axis().scale() +if isinstance(vs, LinearScale): + vs.set_show_zero(False) # a trend reads slope — don't force a zero baseline +ls = tr.line_style() +ls.set_interpolation('monotone') +ls.set_show_points(False) +ls.set_stroke_width(2) +ls.set_missing('hide') +tr.title_style().set_visible(False) +tr.legend().set_visible(False) +tr.set_background_color(None) +tr.set_padding(False) +# Two-series variant (actual + plan or prior year): replace the block from +# tr.set_series(...) through tr.set_series_color(...) — and the legend line — with: +# tr.set_series([ +# (sigma.formula.sum(tr.get_column_by_name('Target')), 'Plan'), +# (sigma.formula.sum(tr.get_column_by_name('Revenue')), 'Total revenue'), +# ]) +# pair = tr.list_series() +# pair[0].set_format('currency_si', decimals=1, currency='$') +# pair[1].set_format('currency_si', decimals=1, currency='$') +# tr.set_series_color(pair[0], SERIES_GREY) # context grey behind +# tr.set_series_color(pair[1], SERIES) # the measure in front +# tr.legend().set_position('top-left') # replaces legend().set_visible(False) +``` + +- Pick the grain from the queried date span — aim for 12–40 marks; a span + inside one year takes `set_format_pattern('%b')`, a `'week'` grain `'%b %d'`. +- **Headline number over the chart** (same card): the card grows to 15, + the chart moves to `(1, 6, 24, 10)`, and a title-less KPI sits between + the subline and the chart — + `hk = sec.add_kpi(src)` · `hk.set_frame(1, 3, 8, 3)` · + `hk.title().set_visible(False)` · `hk.set_value((sigma.formula.sum(hk.get_column_by_name('Revenue')), 'Headline'))` + · value style 24, bold, ink · `hk.set_background_color(None)` · + `hk.set_padding(False)` — the number the line explains, read first. +- **Windowed trend** — the card carries its own date window, scoping this + chart (and its headline KPI) without touching the page's filters: + `win = sigma.control.add_date_range_control(sec, target=[tr.get_column_by_name(''), hk.get_column_by_name('')])` + · `win.set_name('Window')` · `win.set_frame(17, 1, 8, 2)` — the control + is two rows whatever its frame says, so the subline narrows to + `(1, 2, 16, 1)`; no `value=` (a rolling window cannot be a default; the + reader picks one from the control's presets). Drop the `hk` target when + the card has no headline. Keep the month grain here — the window does + the narrowing. +- Actuals + a projection in one frame is its own block: + `blocks/chart-forecast.md`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md new file mode 100644 index 0000000..35b1c92 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chart-waterfall.md @@ -0,0 +1,83 @@ +# Chart: waterfall (bridge) + +Opening figure on the left, one signed step per driver, closing figure on the +right — how a variance is made up. There is no waterfall element: it is a +stacked bar with an invisible plinth holding each step at its running height. +Use for a bridge, a variance walk, a build-up, or "start here, end there". + +The source is **one row per step, in order**: a small helper table on the data +page, never the fact table. It needs a `Step no` to sort by, a short `Step` +label, a signed `Impact`, and a `Kind` of `open` / `step` / `close`. The +`open` row's `Impact` is the opening figure; **the `close` row's is 0** — its +bar is the running total, which is the closing figure by construction, so +putting the figure there doubles it. The four bar columns are computed on +that table, not on the chart: + +```python +wfd = data_page.add_table() +wfd.set_name('Bridge steps') +wfd.set_frame(1, 13, 24, 12) +wfd.set_sort([sigma.sort.ascending(wfd.get_column_by_name('Step no'))]) +imp = wfd.get_column_by_name('Impact') +step = sigma.formula.eq(wfd.get_column_by_name('Kind'), sigma.formula.const_text('step')) +rise = sigma.formula.gt(imp, sigma.formula.const_num(0)) +run = wfd.add_column('Running', sigma.formula.cumulative_sum(sigma.formula.sum(imp))) +# the plinth is the lower edge of this step's bar: running height less the part +# that rises above it. const_null, never 0 — a 0 segment still paints a hairline +# sliver on top of every bar. greatest() returns an untyped Formula, so it needs +# number() before subtract (blocks/formulas.md) +wfd.add_column('Plinth', sigma.formula.if_(step, + sigma.formula.subtract(sigma.formula.number(run), + sigma.formula.number(sigma.formula.greatest(imp, sigma.formula.const_num(0)))), + sigma.formula.const_null())) +wfd.add_column('Rise', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.number(imp), sigma.formula.const_null()), + sigma.formula.const_null())) +wfd.add_column('Fall', sigma.formula.if_(step, + sigma.formula.if_(rise, sigma.formula.const_null(), sigma.formula.abs(imp)), + sigma.formula.const_null())) +wfd.add_column('Anchor', sigma.formula.if_(step, + sigma.formula.const_null(), sigma.formula.number(run))) +``` + +```python +wf = sec.add_bar_chart(wfd.to_source()) +wf.set_frame(1, 3, 24, 12) # H = 14; a shorter card → SKILL.md's section-card rule +wf.set_dimension(wf.get_column_by_name('Step')) +wf.set_sort_by_calculation('dimension', sigma.formula.min(wf.get_column_by_name('Step no')), ascending=True) +wf.set_series([ # top of the stack first — the plinth goes last + (sigma.formula.sum(wf.get_column_by_name('Rise')), 'Step up'), + (sigma.formula.sum(wf.get_column_by_name('Fall')), 'Step down'), + (sigma.formula.sum(wf.get_column_by_name('Anchor')), 'Opening and closing'), + (sigma.formula.sum(wf.get_column_by_name('Plinth')), 'Plinth'), +]) +bars = wf.list_series() +bars[0].set_format('currency_si', decimals=1, currency='$') +bars[1].set_format('currency_si', decimals=1, currency='$') +bars[2].set_format('currency_si', decimals=1, currency='$') +wf.set_stacking('stacked') +wf.set_series_color(bars[0], '#008236') # a rise is good, a fall is bad — the one +wf.set_series_color(bars[1], '#c10007') # chart that takes the semantic pair +wf.set_series_color(bars[2], SERIES_GREY) # the anchors are not a driver +wf.set_series_color(bars[3], '#ffffff') # the plinth, painted out against the card +wf.dimension_axis().set_label_angle(0) +wf.value_axis().set_marks('none') +wf.legend().set_visible(False) # it would name the plinth +wf.title_style().set_visible(False) +wf.set_background_color(None) +wf.set_padding(False) +``` + +- **`set_series` paints the last series at the bottom of the stack**, so the + plinth goes last and the order above is the order to keep. +- The plinth is `'#ffffff'` because it hides against the card, so a waterfall + only ever sits on a white card — never a callout, a loud band, or the canvas. +- Labels are why bridges look broken: `SKILL.md` forbids rotating them and a + waterfall cannot go horizontal, so keep `Step` to a word or two in the + helper table — `sigma.formula.switch_` over the long driver names — rather + than shrinking the type. +- 3–8 steps reads; past that the drivers stop being distinguishable and the + small ones belong in one `Other` step. +- The closing anchor is the opening plus every step by construction, so if it + does not foot, the helper table is wrong and no amount of rebuilding the + chart will fix it — `query` the step rows and check the total first. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md new file mode 100644 index 0000000..c6734cb --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/chat.md @@ -0,0 +1,50 @@ +# Assistant chat + +An agent the reader types questions at, answering from the elements you +attach — it answers what no chart was built for. The shell table names the +placement, and it is a **popover** behind the shell's top-right `Assistant` +trigger in every one — the panel it opens under is where the reader's eye +already is. The chat itself: + +```python +chat = host.add_chat('Answer questions about for .') # host: ai, the popover +chat.set_frame(1, 1, 12, 14) # the popover body is 12 columns +chat.set_agent_name('') +chat.set_agent_description('') +chat.set_instructions( + 'You answer questions about for , from the attached data only. ' + + 'Answer in 2-3 sentences, naming the number that decides it. ' + + 'Say plainly when the data does not answer — never estimate. ' + + 'No tables, no bullet lists, no markdown. ' + + 'You can read and explain; you cannot change the workbook or the data, so never offer to.') +chat.add_source(base) +chat.set_background_color(None) +chat.set_padding(False) # flush — the popover's own padding insets it +``` + +- `ai = sigma.workbook.add_popover(host, 'Assistant')` — the host is the + header band, the header panel's `hc`, or the sidebar strip's `hc` — then + `ai.set_width('x_large')` · the chat above. `blocks/header.md` and + `blocks/sidebar.md` style the trigger. It is the one tall popover. +- The popover **keeps its padding** — never `set_padding(False)` on it. It + is the frame around the agent, not a structural container, and without + the inset the messages and the input box run into its edge. +- A popover has no header and **cannot be opened by an action** — + `open_overlay` on one raises. Its `trigger()` is the only way in, so it + never takes a button beside it; style that trigger instead of adding one. + A drawer is what an *action* opens (`blocks/queue.md`, `blocks/detail.md`). +- **Apps** attach the write table as well (`chat.add_source(it)` — `lt` for + a grid or queue) so the agent reads the log or queue, and add to the + instructions: 'You can read the plan, queue, or log; you cannot change + it — say so if asked.' +- Sources are **column-bearing elements only** — the base table, an input + table, a chart, a KPI, a pivot. A text element, container, or control + fails at typecheck. `add_source` adds one, `set_sources([...])` replaces + the set. Attach `base` for the whole grain and the page's main surface; + never more than 3 sources. +- Instructions are **static text**: `{{...}}` stays literal, so scope the + agent in words. There is no greeting, no starter questions, and no tools + on this surface — a brief that promises to file or update anything + yields an agent that apologizes; state the limit instead. +- Always set both names — the default is a bare `Agent`, `Agent 2`, the way + a page defaults to "Page 1". diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md new file mode 100644 index 0000000..4972158 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/compare-strip.md @@ -0,0 +1,46 @@ +# Compare strip + +A white card holding a text grid — two states of the same measures side by side with the delta, read in one glance (baseline vs working plan, last vs this quarter). Header row of centered muted labels, one row per measure, delta column in `PRIMARY`. + +```python +cs = page.add_container() +cs.set_frame(1, y, 24, 2) # header row + one measure row; +1 per extra measure +cs.set_column_density('high') +cs.set_spacing('small') +cs.set_background_color('#ffffff') +cs.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +h1 = cs.add_text('

****

') +h1.set_frame(7, 1, 6, 1) +h2 = cs.add_text('

****

') +h2.set_frame(13, 1, 6, 1) +h3 = cs.add_text('

**Change**

') +h3.set_frame(19, 1, 6, 1) +r1 = cs.add_text('

**Revenue**

') +r1.set_frame(1, 2, 6, 1) +r1.set_vertical_align('middle') +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +r1b = cs.add_text( # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + '

{{Baseline}}

', + formulas={'Baseline': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M')))}) +r1b.set_frame(7, 2, 6, 1) +r1b.set_vertical_align('middle') +r1c = cs.add_text( + '

**{{Current}}**

', + formulas={'Current': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M')))}) +r1c.set_frame(13, 2, 6, 1) +r1c.set_vertical_align('middle') +r1d = cs.add_text( + '

**{{Change}}**

', + formulas={'Change': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(rev, tgt), mm), 1)), sigma.formula.const_text('M')))}) +r1d.set_frame(19, 2, 6, 1) +r1d.set_vertical_align('middle') +``` + +- Derive the delta with `sigma.formula.subtract(current, baseline)`; its color stays `PRIMARY` — it is a value, not a verdict (good/bad ink belongs to conditional formats on tables). +- One measure per row, patterned exactly on the row shown; each extra measure adds one row and one row of card height (2 measures → height 3). +- The working state always reads heavier than the reference — current bold, baseline regular. +- Every `{{...}}` binds over **`base`'s** columns; placeholder keys are per-element, so every row reuses 'Baseline' / 'Current' / 'Change'. +- Past ~4 measures this stops being a glance — use a table card instead. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md new file mode 100644 index 0000000..94143f7 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/context-strip.md @@ -0,0 +1,41 @@ +# Context strip + +One white strip announcing the page's active state — which scenario is live, how many overrides, the net effect — with the switcher and a quiet escape hatch in the same row. Earns its place on any page whose numbers depend on a selection. + +```python +ctx = page.add_container() +ctx.set_frame(1, y, 24, 2) +ctx.set_column_density('high') +ctx.set_spacing('small') +ctx.set_background_color('#ffffff') +ctx.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +net = sigma.formula.sum_if(base.get_column_by_name(''), sigma.formula.is_not_null(base.get_column_by_name(''))) +state = ctx.add_text( + '

Active scenario: **{{Scenario}}** · {{Edits}} overrides · net **{{Impact}}**

', + formulas={ + 'Scenario': sigma.formula.max(base.get_column_by_name('')), + 'Edits': sigma.formula.count_if(sigma.formula.is_not_null(base.get_column_by_name(''))), + 'Impact': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(net, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), # pre-formatted (blocks/formulas.md); a count binds bare + }) +state.set_frame(1, 1, 12, 2) +state.set_vertical_align('bottom') # level with the control's input; the label takes row 1 (blocks/filters.md) +sel = sigma.control.add_list_control(ctx, target=base.get_column_by_name(''), allows_multi=False, value='') +sel.set_name('Active scenario') +sel.set_frame(13, 1, 6, 2) +# a list control can be cleared, and cleared it un-scopes the page. When the selection +# feeds a lookup or a model, use blocks/filters.md's segmented control instead (≤4 +# values) — it has no clear affordance, so the page can never resolve against no scenario +btn = ctx.add_button('Switch scenario →') +btn.set_frame(19, 2, 6, 1) +btn.set_appearance('text') +btn.set_font_color(MUTED) +btn.set_bold(False) +btn.set_align('right') +btn.set_button_size('small') +``` + +- `{{Scenario}}` resolves to the active value because the control filters `base` — after the filter, `max` over the column returns the one selected name. +- Wire the button per `blocks/buttons.md` (usually navigate to the page where switching really happens); drop it when there is nowhere to go. +- The strip must never open empty — a `value=` default only when the first render still shows data. +- Everything inline in one `

` — it reads as a sentence, not a KPI row. +- Sits directly under the title rule, before the KPI row; the control spends row 1 on its label, so the sentence and the button settle onto row 2 with it. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md new file mode 100644 index 0000000..0c2d794 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/detail.md @@ -0,0 +1,168 @@ +# Record detail — pick a record, see the whole thing + +Seeing one whole record is the move a reader reaches for first on any +table of records, so **every detail table on a work surface carries it**. +A **click cannot carry the row**: an effect's value is a literal, another +control's live value, or a formula over the *whole* element, and the SDK +has no Selection source — so a value read off the clicked row lands null +and the record renders blank. Trigger conditions do read the clicked row; +values never do. A **picker** sets the record instead. Three surfaces +hold it, and the reader's next move picks which: + +| Surface | Reach for it when | Width | +|---|---|---| +| **Modal** — the default | reading one record, or a decision that must finish before the page is usable again | `'medium'`; `'x_small'` to confirm, `'large'` for a wizard (`blocks/wizard.md`) | +| **Drawer** | the list must stay readable beside it — scanning row after row, deciding down a queue (`blocks/queue.md`) | `'small' 'medium' 'large'` only | +| **Popover** | a few lines of "what am I looking at", opened from its own trigger — never from the picker | the bullets below | + +The record is a **single-row container**: an element with its own frame +that is also a host, so text, value lists, KPIs, and buttons go inside it +and read the row it shows. `select_row` on the picker's `on_change` sets +that row; it opens on `initial_value` and holds the last row set, so +`initial_value` is a **real key from step 3** — the surface must read +correctly before anything is picked. The **key column is also what the +picker lists**, so key on the column a reader recognises — a name, a ref +— and fall back to an id only when nothing readable is unique. Controls +never sit on the overlay itself — on a container in it, and the picker on +the list's own card. + +Its source is **one row per key**, the rule of `blocks/input-table.md`: a +grouped table, never a raw one. The container filters that source to the +key, so of its columns only the grouping key and the **calculations** hold +one value — the base columns trailing them render the literal text +`Multiple values`. Every attribute the card shows is a calculation too — a +label's is `sigma.formula.max`. + +```python +one = data_page.add_table(src) # the container's source +one.set_name('Record source') +one.set_frame(1, 49, 24, 8) # a free data-page slot — the archetype's own bases take 13 through 37 +g = one.set_groupings([one.get_column_by_name('')])[0] +g.set_calculation('Total revenue', sigma.formula.sum(sigma.formula.multiply(one.get_column_by_name(''), one.get_column_by_name('')))) +g.set_calculation('Orders', sigma.formula.count(one.get_column_by_name(''))) +g.set_calculation('Owner', sigma.formula.max(one.get_column_by_name(''))) + +m = sigma.workbook.add_modal('Account detail') +m.set_width('medium') +m.set_header(title='Account detail', show_close_icon=True) +m.set_footer_buttons(primary_text='Done', show_primary=True, show_secondary=False) +body = m.add_container() +body.set_frame(1, 1, 12, 8) +body.set_column_density('medium') +body.set_padding(False) +body.set_gap(False) +card = body.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(1, 1, 12, 8) +cols = card.list_columns() # its only column lookup: the key first, then the calculations in the order set above +key, rev, orders, owner = cols[0], cols[1], cols[2], cols[3] +name = card.add_text('

**{{Name}}**

', formulas={'Name': key}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # a column binds bare — no aggregate, no text() wrapper; money and rates pre-format first (blocks/formulas.md) + 'Revenue': sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))), + 'Orders': orders, + 'Owner': owner, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 4, 12, 5) # 2 rows per field, less one: 3 fields → 5, 4 → 7 + +m.on_primary_click('done', [sigma.action.overlayEffects.close_overlay()]) + +pick = sigma.control.add_list_control(sec, target=one.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # sec = the card holding the list; targeting `one` (the container's own source) leaves that list whole +pick.set_frame(17, 1, 8, 2) # the card's label row, so its subline narrows to (1, 2, 16, 1) +pick.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(m), +]) +``` + +`pick`, `card`, and `m` are handles of the program that built them. From a +**later** call every one of them is re-fetched and narrowed first, because +`get_element` returns the base `Element` and `get_page` the base `PageBase` +— no `on_change`, and `open_overlay` rejects a page: + +```python +page = sigma.workbook.get_page('') # every id from the build's state dump +el = page.get_element('') +ov = sigma.workbook.get_page('') +host = ov.get_element('') +if not (isinstance(ov, Modal) and isinstance(host, Container)): + raise RuntimeError('re-fetch') +card = host.get_element('') +if not (isinstance(el, ListControl) and isinstance(card, SingleRowContainer)): + raise RuntimeError('re-fetch') +el.on_change('open detail', [ + sigma.action.setSingleRowViewEffect.select_row(card, el), + sigma.action.overlayEffects.open_overlay(ov), +]) +``` + +- A button drives the same card with a constant: + `b.on_click('', [sigma.action.setSingleRowViewEffect.select_row(card, sigma.formula.const_text(''))])`. + Never open the overlay on `tbl.on_select`: the click cannot say which + record, so it would open on whatever was last picked. +- The record's own **rows** under the card — its orders, its line items — + need the room: `body.set_frame(1, 1, 12, 18)`, the card still `(1, 1, 12, + 8)`, that table at `(1, 9, 12, 10)`. Add its key column to the picker's + `target` list (`target=[…, rows.get_column_by_name('')]`) rather + than adding a second control. Filter that table, never `base` — that + would re-scope every page. +- A modal's footer styles itself — primary renders solid in the theme + highlight (`PRIMARY`), secondary neutral — and **both CTAs show by + default**, so a modal that supplies its own buttons hides them explicitly + (`show_primary=False, show_secondary=False`, as the wizard does). A + decision takes both: `set_footer_buttons(primary_text='Approve', + show_primary=True, secondary_text='Cancel', show_secondary=True)` wired + with `m.on_primary_click(...)` / `m.on_secondary_click(...)`. +- **Drawer** instead — the same body, swapping the three opening lines for + `drawer = sigma.workbook.add_drawer('Account detail')` · + `drawer.set_width('medium')` · + `drawer.set_header(title='Account detail', show_close_icon=True)`, and + `open_overlay(drawer)` on the picker. It has no footer, so its actions + are buttons in the body (`blocks/queue.md`), and `set_width` takes only + the three sizes above — a modal's `x_small`/`x_large` raise on one. +- **Info popover** — the "what am I looking at" affordance on a card's + label row, quiet trigger included: + ```python + info = sigma.workbook.add_popover(sec, 'About this view') + tip = info.add_text('

') + tip.set_frame(1, 1, 12, 3) + trig = info.trigger() + trig.set_text('ⓘ') + trig.set_appearance('text') + trig.set_font_color(MUTED) + trig.set_frame(23, 1, 2, 1) + trig.set_align('right') + ``` + A popover body caps near 40% of the viewport — a tip is a few lines; the + one tall popover is the Assistant chat (`blocks/chat.md`). +- Decision buttons in the overlay write rows: `blocks/queue.md`. + +## Record card — the picked row, on the page + +The same container as an **8-column card beside the list** (pair 16/8) +instead of inside an overlay: pick a record and the card reads it, no +overlay to open — the shape a Ledger reaches for first, since the list +and the record then sit side by side. Only the wrapper changes — the +source, the `cols` unpack, and the bindings are the fence above, on those +same 12-wide frames: a +single-row container is 12 inner columns, and this one takes no density +call. + +```python +g.set_calculation('Kind', sigma.formula.max(one.get_column_by_name(''))) # the fourth calculation on `one`, so index 4 below +card = page.add_single_row_container(one.to_source(), key_column=one.get_column_by_name(''), initial_value='') +card.set_frame(17, y, 8, 12) +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +kind = card.list_columns()[4] +eye = card.add_text('

{{Kind}}

', formulas={'Kind': sigma.formula.upper(sigma.formula.text(kind))}) +eye.set_frame(1, 1, 12, 1) # the eyebrow takes row 1, so name moves to (1, 2, 12, 2) and facts to (1, 5, 12, 5) +go = card.add_button(' →') # soft button, stretched (blocks/buttons.md) +go.set_frame(1, 11, 12, 2) +pick.on_change('pick', [sigma.action.setSingleRowViewEffect.select_row(card, pick)]) # the picker of the fence above, on the list's card +``` + +- Aligned label/value columns instead of the far-edge look: + `facts.set_layout('grid')` + `facts.set_label_width(0.5)` — only `'grid'` layout draws label width. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md new file mode 100644 index 0000000..b74110d --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/filters.md @@ -0,0 +1,108 @@ +# Filters + +Controls target **base** columns so one filter re-scopes everything built +from the base. Pick the control from the column's shape: few distinct +values → segmented · many → list · date → `add_date_range_control` · +number → `add_number_range_control` · a single threshold → `add_slider_control(bar, value=50000, target=..., range=(0, 250000, 5000), compare_op='>=')` +(`compare_op` is `'='`, `'<='`, or `'>='`) · boolean → `add_switch_control` — +all take `(host, value=, target=)` like the three below; a number-range +`value=` is a `(min, max)` tuple. A **type-to-find search box** over a name +column is `add_text_control` with a `match_mode` — required on any surface a +reader looks records up in (a table or card feed of deals, accounts, +tickets, transactions), and what a name or id column of more than ~25 +distinct values takes instead of a list control: + +```python +find = sigma.control.add_text_control(bar, target=base.get_column_by_name(''), + match_mode='contains', case_sensitive=False, placeholder='') +find.set_name('Search') # 'Search' labels it, the placeholder names the column — never both +find.set_frame(4, 1, 10, 2) # leads the bar, after the label; two filters follow at (14,1,5,2) and (19,1,6,2) +``` + +Target the **base** name column, so the search re-scopes the counts and the +KPI band under the table too — a child table only when that child is the +surface (a queue, a card feed's `lst`). `match_mode` is `'contains' +'starts_with' 'ends_with' 'exactly' 'like' 'regex'`; `'exactly'` pins a +surface to one record — the table under a detail card (`blocks/detail.md`), +the row an approval panel writes to (`blocks/plan-grid.md`) — not a search. +Both text controls also run variable-only with no `target=`. + +## Toolbar — up to ~4 filters + +One card strip above the content it governs. A search leads it and costs +two of the four slots — the frames under it shift as the snippet above says. + +```python +bar = page.add_container() +bar.set_frame(1, y, 24, 2) +bar.set_column_density('high') +bar.set_spacing('small') +bar.set_background_color('#ffffff') +bar.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +bl = bar.add_text('

Filters

') +bl.set_frame(1, 1, 3, 2) +bl.set_vertical_align('middle') +region = sigma.control.add_list_control(bar, target=base.get_column_by_name('Region')) +region.set_name('Region') +region.set_frame(4, 1, 5, 2) +dates = sigma.control.add_date_range_control(bar, target=base.get_column_by_name('')) +dates.set_name('Period') +dates.set_frame(9, 1, 5, 2) +seg = sigma.control.add_segmented_control(bar, options=['
', '', ''], value='', target=base.get_column_by_name('')) +seg.set_name('Status') +seg.set_frame(14, 1, 6, 2) +``` + +## Full-height columns — a rail, a form, or a record list beside the work + +The rail is a card — white, hairline, padded — and the content column +beside it is structural. Both take the same `y` and the same height, and +the y-cursor resumes below them. + +```python +rail = page.add_container() +rail.set_frame(1, y, 5, 30) +rail.set_column_density('low') # 6 inner columns +rail.set_spacing('small') +rail.set_background_color('#ffffff') +rail.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +rl = rail.add_text('

Filters

') +rl.set_frame(1, 1, 6, 1) +r1 = sigma.control.add_list_control(rail, target=base.get_column_by_name('Region')) +r1.set_name('Region') +r1.set_frame(1, 2, 6, 2) +# stack further controls at y 4, 6, 8 … +main = page.add_container() +main.set_frame(6, y, 19, 30) +main.set_column_density('high') +main.set_padding(False) # structural — cards go inside it +``` + +- The same two containers mirror and resize for the other page splits: + `main (1,y,18,H)` + `rail (19,y,6,H)` for a running summary or approval + panel the reader watches while working; `panel (1,y,8,H)` + + `main (9,y,16,H)` when the left column is a form or a record list. That + column is a card like the rail — the same fill, border, and padding + calls; only `main` is structural. +- A control created with `add_*_control` has no label kwarg — always + `set_name(...)` after, and give it a real short one. **`set_name('')` does + not blank a label** — it falls back to the target column's name, so a + picker meant to read bare renders `Opportunity Close Fiscal Quarter` + instead. A label never moves beside the input, and hiding it buys no row + back, so budget the row for it and name it in two words. A two-row + control spends **row 1 on the label and row 2 on the input** — so + anything that must read level with it lines up with row 2, not with the + frame: a button is framed `(x, 2, w, 1)`, a text `(x, 1, w, 2)` with + `set_vertical_align('bottom')`. Buttons have no vertical align of their + own. +- Set `value=` defaults only when the first render still shows data; a + filtered-to-empty opening page is a build failure. +- A date-range default is absolute datetimes — + `value=(datetime(2026, 1, 1), datetime(2026, 6, 30))`; `datetime(y, m, d)` + is available bare. A rolling window ("last 90 days") cannot be a control + default — leave it unset, or flag rows with `sigma.formula.date_add` + (`blocks/formulas.md`). +- `target=` accepts a list to filter several base tables' columns at once + (`[orders.get_column_by_name('Region'), targets.get_column_by_name('Region')]`). +- The segmented control's `options` order is display order — pass a + chronological/logical list, not whatever `query` returned. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md new file mode 100644 index 0000000..7e32c09 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/forecast.md @@ -0,0 +1,70 @@ +# Forecast grid app — a value per entity per period + +The reader forecasts by month or quarter per entity (family, product, rep, +region). Pages **Overview · Forecast · Review** — the bands of +`blocks/plan-grid.md` with the grid swapped: its rows are **entity × period**, +a seeded date spine cross-joined to the entities, last year's actuals beside +the editable forecast. + +## Call 1 — opening, then the backbone on the data page + +```python +spine = data_page.add_input_table('') # the source's own connection — a join needs both sides on one +spine.set_name('Date spine') +spine.set_frame(1, 13, 24, 6) +s_month = spine.add_editable_column('Month', 'datetime') +spine.upsert_rows([{s_month: datetime(2026, 10, 1, 12)}, {s_month: datetime(2026, 11, 1, 12)}, {s_month: datetime(2026, 12, 1, 12)}]) # 3 shown — seed every period of the horizon (12 months or 4–8 quarters), noon +entities = data_page.add_table(src) # one row per entity +entities.set_name('Families') +entities.set_frame(1, 19, 24, 6) +eg = entities.set_groupings([entities.get_column_by_name('')])[0] +eg.set_calculation('Units', sigma.formula.sum(entities.get_column_by_name('Quantity'))) +actuals = data_page.add_table(src) # entity × month history +actuals.set_name('Actuals by month') +actuals.set_frame(1, 25, 24, 6) +am = actuals.add_column('Actual month', sigma.formula.date_trunc('month', actuals.get_column_by_name(''))) +ag = actuals.set_groupings([actuals.get_column_by_name(''), am])[1] # [1] — the inner level carries the calc +ag.set_calculation('Actual revenue', sigma.formula.sum(sigma.formula.multiply(actuals.get_column_by_name('Price'), actuals.get_column_by_name('Quantity')))) +matrix = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(entities.to_source(), eg), sigma.data.source.SourceInput(spine.to_source()), 'left_outer', [], key_formulas=[('1', '1')])) # the cross join +matrix.set_name('Entity x month') +matrix.set_frame(1, 31, 24, 6) +fsrc = data_page.add_table(sigma.data.source.join(sigma.data.source.SourceInput(matrix.to_source()), sigma.data.source.SourceInput(actuals.to_source(), ag), 'left_outer', [], + key_formulas=[('[]', '[]'), ("DateTrunc('month', [Month])", "DateAdd('year', 1, [Actual month])")])) # last year's same month +fsrc.set_name('Forecast source') +fsrc.set_frame(1, 37, 24, 6) +``` + +## Call 2 — the grid, in the Forecast page's section card + +```python +fsrc = data_page.get_element('') # re-fetch, with the pages as blocks/input-table.md's call 2 opens +if not isinstance(fsrc, LevelTable): + raise RuntimeError('re-fetch') +ft = sec.add_linked_input_table(fsrc.to_source(), lookup_columns={'': 'Family', 'Month': 'Month'}, source_columns={'Actual revenue': 'Last year'}) +ft.set_name('Monthly forecast') +ft.set_frame(1, 3, 24, 16) +f_val = ft.add_editable_column('Forecast', 'number') +f_why = ft.add_editable_column('Note', 'text') +f_ly = ft.get_column_by_name('Last year') +f_delta = ft.add_formula_column('vs last year', sigma.formula.subtract(f_val, f_ly)) +ft.set_frozen_column(ft.get_column_by_name('Family')) +ft.get_column_by_name('Month').set_format_pattern('%b %Y') +ft.set_sort([sigma.sort.ascending(ft.get_column_by_name('Family')), sigma.sort.ascending(ft.get_column_by_name('Month'))]) +# then the style calls, currency formats, and Delta conditional formats of blocks/input-table.md +``` + +## Step 5 call — seeds, by the composite key + +```python +ft.update_rows([ # fam, mon from get_column_by_name; f_val, f_why narrowed + ({fam: 'Audio', mon: datetime(2026, 10, 1, 12)}, {f_val: 8200000, f_why: 'Launch month'}), + ({fam: 'Audio', mon: datetime(2026, 11, 1, 12)}, {f_val: 9100000, f_why: 'Holiday ramp'}), + ({fam: 'Smart Phones', mon: datetime(2026, 10, 1, 12)}, {f_val: 7600000}), +]) +``` + +- A quarter grid uses `date_trunc('quarter', …)` and quarter-start seeds. +- Everything else is `blocks/plan-grid.md` over `ft.to_source()` — KPI row, + compare strip, submit and review wiring — except Overview's pending band, + which is `blocks/chart-trend.md`'s two-series variant (Last year behind + Forecast, by Month) at `(1, 15, 24, 14)`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md new file mode 100644 index 0000000..f0d09c0 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/formulas.md @@ -0,0 +1,107 @@ +# Formulas beyond the blocks + +The calculations dashboards commonly need that no other block demonstrates — +signatures as the SDK defines them. Wrap literals in formula args: +`const_num(0.5)` · `const_int(3)` · `const_text('West')` · `const_bool(True)`; +comparisons are `gt / gte / lt / lte / between / eq / neq`. + +**Nesting one formula inside another needs a wrap.** `lookup` `greatest` +`least` `lag` `coalesce` `if_` `grand_total` `max` `min` all return an +untyped `Formula`, and every typed argument — `divide` `subtract` `multiply` +`abs` `cumulative_sum` `zn` — rejects it: `expects NumberFormula | Column but +got Formula`. Put `sigma.formula.number(...)` (or `text` / `date`) around the +inner call: `zn(number(lookup(...)))`. `sum` and `avg` are already typed and +need no wrap. + +```python +# Averages and distincts — same shape as sum / sum_if everywhere +aov = sigma.formula.avg(base.get_column_by_name('Revenue')) +west = sigma.formula.avg_if(base.get_column_by_name('Revenue'), + sigma.formula.eq(base.get_column_by_name('Region'), sigma.formula.const_text('West'))) +mid = sigma.formula.median(base.get_column_by_name('Revenue')) +buyers = sigma.formula.count_distinct(base.get_column_by_name('')) + +# Conditional value — status labels, bucketing +health = sigma.formula.if_( + sigma.formula.gte(base.get_column_by_name('Revenue'), base.get_column_by_name('Target')), + sigma.formula.const_text('On track'), sigma.formula.const_text('Behind')) +# 3+ branches: switch_(value, [(match, result), ...], default_value=...) + +# Share of total — a percent that sums to 100% across the rows shown +share = sigma.formula.percent_of_total(sigma.formula.sum(base.get_column_by_name('Revenue')), 'grand_total') + +# Prior row / period-over-period — lag shifts the column down by offset rows +# (a plain int), so sort the element by its date grain ascending first +prior = sigma.formula.lag(sigma.formula.sum(base.get_column_by_name('Revenue')), 1) + +# Running total — sort the element by its date grain ascending first +ytd = sigma.formula.cumulative_sum(sigma.formula.sum(base.get_column_by_name('Revenue'))) + +# Pull a value across tables by key — targets, quotas, owners +quota = sigma.formula.lookup(targets.get_column_by_name('Quota'), + base.get_column_by_name('Region'), targets.get_column_by_name('Region')) + +# Nulls: zn -> 0 on a column or a typed number formula; coalesce wraps any formula +clean = sigma.formula.zn(base.get_column_by_name('')) +safe = sigma.formula.coalesce(sigma.formula.lookup(...), sigma.formula.const_num(0)) + +# Date windows — a rolling flag to filter or sum_if by +recent = sigma.formula.gte(base.get_column_by_name(''), + sigma.formula.date_add('day', sigma.formula.const_int(-90), sigma.formula.today())) +age_days = sigma.formula.date_diff('day', base.get_column_by_name(''), sigma.formula.today()) +``` + +**Pre-format for prose** — a `{{placeholder}}` takes no format spec, and +`text()` on **any** number renders full float precision, aggregate or plain +column (`287243281.43`, `85.0000000000000000000`). So every value bound into +a sentence, or joined with `ampersand` into a column, is rounded in the +formula — `text(round(x, n))`, never `text(x)`: + +```python +# money +money = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.divide(total, sigma.formula.const_num(1000000)), 1)), + sigma.formula.const_text('M'))) +# a rate +pct_txt = sigma.formula.ampersand( + sigma.formula.text(sigma.formula.round(sigma.formula.multiply(rate, sigma.formula.const_num(100)), 1)), + sigma.formula.const_text('%')) +``` + +Collapsing an append-only history to **one current row per entity** — the +step before `lookup` — is a rank filtered to 1 on a grouped helper table. +Without it every aggregate sums the whole history and reads plausibly wrong: + +```python +latest = data_page.add_table(base.to_source()) +latest.set_name('Latest submission') +latest.set_groupings([latest.get_column_by_name('')]) +rk = latest.add_column('Submission rank', + sigma.formula.row_number(latest.get_column_by_name(''), 'desc')) +rk.set_filter(sigma.filter.values([1])) +``` + +- Every result binds like any block's formula: a `(formula, '')` + calc, a `formulas={}` placeholder, or a condition inside `sum_if`. +- `rank(column, 'desc')` numbers rows for a table column; a top-N **chart** + stays `sigma.filter.top_n` (`blocks/chart-ranked.md`). +- `date_add` / `date_diff` units: `'year' 'quarter' 'month' 'week' 'day' + 'hour' 'minute' 'second'`. `lag`/`lead` offsets are plain ints; + `date_add` amounts are formulas (`const_int`). +- A grouped table sorts on the **grouping**, not the table: + `g = tbl.list_groupings()[0]` · `g.set_sort([...])`. `tbl.set_sort` on a + grouped table raises "not a base-level column". +- `set_groupings` returns one level per column and a calculation goes on the + **last** one — `[0]` is right only for a single grouping, and on two it + silently computes against the outer level and renders zeros. +- A date grain is `date_trunc` + `set_format_pattern`, never `date_format`: + its pattern is strftime (`'%Y-%m'`, `'%b %Y'`), and a wrong token renders + the pattern text itself on every row, collapsing the grouping to one value + without erroring. +- Weekdays sort alphabetically unless told otherwise — add + `wd = el.add_column('Weekday number', sigma.formula.weekday())`, then + sort by it. `set_sort_by_calculation` takes the **column** on a pivot + (`piv.set_sort_by_calculation(piv.get_column_by_name(''), sigma.formula.min(wd), ascending=True)`) + and a **channel** on a chart (`chart.set_sort_by_calculation('dimension', sigma.formula.min(wd), ascending=True)`). +- `set_format('currency', ...)` renders `USD28,412`; `'currency_si'` with + `currency='$'` gives `$28.4K`, which is what the blocks use. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md new file mode 100644 index 0000000..caca93e --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/header.md @@ -0,0 +1,86 @@ +# Header + +The identity strip — white, a hairline, the workbook's subject as the +lockup. **Band** for one page, **panel** for 2+ reading +pages (the shell table); three or more reading pages and every app take the +rail and strip of `blocks/sidebar.md` instead — what is never built is two +navs. Both variants here carry the `Assistant` popover trigger +(`blocks/chat.md`). + +## Band — single-page + +```python +band = home.add_container() +band.set_frame(1, 1, 24, 2) +band.set_column_density('high') +band.set_spacing('small') +band.set_background_color('#ffffff') +band.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +brand = band.add_text('**** · ') # **Revenue planning** · FY26 +brand.set_frame(1, 1, 14, 2) +brand.set_vertical_align('middle') +marker = band.add_text('

Overview

') +marker.set_frame(15, 1, 5, 2) +marker.set_vertical_align('middle') +ai = sigma.workbook.add_popover(band, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(20, 1, 5, 2) +trig.set_align('right') +``` + +## Shared panel + nav — multi-page + +Panel calls live on `Page`, which is what `add_page` returns — so every +page of the fixed opening hosts one directly. Controls and buttons never sit +on the panel itself — on a container in it. + +```python +header = scenarios.add_header() # scenarios came from add_page +header.set_background_color('#ffffff') +header.set_border_style('line') +header.set_scroll_behavior('fixed') # navigation requires a fixed header +lockup = header.add_text('**** · ') +lockup.set_frame(1, 1, 8, 2) +lockup.set_vertical_align('middle') +nav = header.add_navigation() # auto: follows visible pages +nav.set_frame(9, 1, 7, 2) +nav.set_alignment('right') +nav.set_style('pill') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +hc = header.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(16, 1, 9, 2) +hc.set_column_density('high') +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # two words — a blank name renders the raw column name; only when describe returned such a column, else drop it +pick.set_frame(1, 1, 16, 2) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +trig = ai.trigger() +trig.set_text('Assistant') +trig.set_appearance('text') +trig.set_font_color(MUTED) +trig.set_frame(17, 2, 8, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +trig.set_align('right') + +home.set_header(header) # every page the nav covers, one for one +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- The lockup is what the workbook is and for whom — `**Revenue planning** · + FY26`, `**Pipeline review** · Sales` — never an invented product name and + never "Dashboard". A logo goes in front of it only when the user gives a + URL: `mark = band.add_image('')` at `(1, 1, 1, 2)` (`(1, 1, 1, 1)` on + the panel), the lockup one column right and one narrower; no styling calls, never + the `'placeholder'` preset, a stock photo. +- The header grid is 24 columns and grows to its content, hard cap 8 rows + — keep it to the two rows above, which is what a labelled control needs. + The band's marker names the current page. +- Hide tabs **only** after the nav reaches every visible page (auto mode + does; the hidden data page needs no link). diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md new file mode 100644 index 0000000..f310907 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/hero.md @@ -0,0 +1,51 @@ +# Hero band + +The one loud surface — at most one per page, carrying the number that decides the meeting. + +```python +hero = page.add_container() +hero.set_frame(1, y, 24, 5) +hero.set_column_density('high') +hero.set_spacing('small') +hero.set_background_color(PRIMARY) +hero.set_border(sigma.border.solid(radius='round')) # radius alone rounds the corners — draws no line +gap = sigma.formula.subtract(sigma.formula.sum(base.get_column_by_name('Target')), sigma.formula.sum(base.get_column_by_name('Revenue'))) +gap_text = sigma.formula.ampersand(sigma.formula.const_text('$'), sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(gap, sigma.formula.const_num(1000000)), 1)), sigma.formula.const_text('M'))) # pre-formatted (blocks/formulas.md) — bound bare, a raw float in 24px type +copy = hero.add_container() # structural: statement + support line read as one unit +copy.set_frame(1, 1, 15, 5) +copy.set_column_density('high') +copy.set_padding(False) +copy.set_gap(False) +copy.set_background_color(None) +copy.set_border(None) +hs = copy.add_text('### {{Gap}} to close the year', formulas={'Gap': gap_text}) +hs.set_frame(1, 1, 24, 3) +hs.set_vertical_align('middle') +hsub = copy.add_text('

Plan says .

') +hsub.set_frame(1, 4, 24, 2) # the copy fills the band — no trailing empty row +hk = hero.add_kpi(src) +hk.set_frame(17, 1, 8, 5) +hk.set_name('Committed pipeline') +hk.set_value((sigma.formula.sum(hk.get_column_by_name('')), 'Committed')) +hk.get_column_by_name('Committed').set_format('currency_si', decimals=1, currency='$') +hk.title().set_color(EDGE) +hk.title().set_font_size(14) +hk.value_style().set_color('#ffffff') +hk.value_style().set_font_size(30) +hk.value_style().set_bold(True) +hk.layout().set_anchor('left') +hk.set_background_color(None) +hk.set_padding(False) +``` + +- **Statement + next action** is the band's other shape: the copy container + widens to 17 columns and one button takes `(18, 1, 7, 5)` with `set_button_size('large')` + and `set_align('right')` — never `'stretch'`, which inflates the pill to a + quarter of the page. Name the step ("Begin the variance review →"), and use + it in place of the KPI when the page's job is to send the reader onward. +- A Briefing dashboard takes the statement + KPI shape above; statement + + next action when the workbook has a page to send the reader to. +- Pair it with a next-action — 2–3 named records (the deals, the line items that close the gap) or a button (`blocks/buttons.md`); the loud number alone is a poster, not a page. +- Every element on the band restates its colors — anything left at a default renders ink-on-ink and disappears. +- The statement is `###` — the page-title size, white on `PRIMARY`; the + support line is never white. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md new file mode 100644 index 0000000..e2f5c5a --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/input-table.md @@ -0,0 +1,125 @@ +# Input table + +The write surface. **Empty**: rows arrive from a form or a button — an intake +log, a decision log. **Linked**: mirrors a source row-for-row and adds +editable columns beside it — a plan grid, a review queue. Both open with seed +rows so the app demonstrates itself. Order is load-bearing: table → editable +and select columns → formula columns → system columns → style → seed rows → +form. A form built before a column lacks that field; a seed row naming a +column not yet added fails. + +## Empty table + +```python +it = sec.add_input_table('') # the base source's connection (describe prints it) +it.set_name('Requests') +it.set_edit_mode('published-all-users') # viewers can write; the default 'draft' is workbook editors only, 'published-explore' explorers and editors +it.set_frame(1, 3, 24, 14) # 2 x rows + 2: a pill row is two grid rows, and so is the header +i_name = it.add_editable_column('Request', 'text') +i_amt = it.add_editable_column('Amount', 'number') +i_date = it.add_editable_column('Needed by', 'datetime') +i_status = it.add_single_select_column('Status', ['Pending', 'Approved', 'Rejected', 'Needs changes']) # pills, coloured by option index — so only for a status vocabulary in that order; a neutral one (scenario, tier, region) is a plain text column +i_urgent = it.add_editable_column('Urgent', 'boolean') +sys = it.add_system_columns(['row_id', 'created_at', 'created_by']) +i_id = sys[0] +if not isinstance(i_id, InputTableIDColumn): # add_system_columns returns InputTableSystemColumn; update_rows' RowSelector rejects that + raise RuntimeError('row id') +for s in sys: + s.set_visible(False) # kept for actions and the audit trail, not for reading +its = it.table_style() +its.set_preset('presentation') +its.set_grid_lines('horizontal') +its.header_style().set_color(MUTED) +for slot in [its.header_style(), its.cell_style()]: + slot.set_background_color('#ffffff') +it.title_style().set_visible(False) +it.set_summary_bar(False) # the row/column count strip under the grid +it.set_background_color(None) +it.set_padding(False) +it.set_no_data_label('NO REQUESTS YET — ADD THE FIRST ONE ON THE LEFT') +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Rejected')), background='#ffe2e2')) +it.add_conditional_format(it.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(i_status, sigma.formula.const_text('Approved')), background='#dcfce7')) +it.upsert_rows([ # 6 seed rows, one call; values from step 3's query + {i_name: '', i_amt: 12400, i_date: datetime(2026, 9, 12, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 48000, i_date: datetime(2026, 10, 3, 12), i_status: 'Approved', i_urgent: False}, + {i_name: '', i_amt: 9500, i_date: datetime(2026, 9, 30, 12), i_status: 'Needs changes', i_urgent: False}, + {i_name: '', i_amt: 3100, i_date: datetime(2026, 9, 20, 12), i_status: 'Rejected', i_urgent: False}, + {i_name: '', i_amt: 22000, i_date: datetime(2026, 11, 1, 12), i_status: 'Pending', i_urgent: True}, + {i_name: '', i_amt: 15800, i_date: datetime(2026, 10, 17, 12), i_status: 'Pending', i_urgent: False}, +]) +``` + +## Linked table — editable columns beside a governed source + +The source must have **one row per key**: a grouped table on the data page +(below), or step 3's `query` showed `COUNT(*) = COUNT(DISTINCT key)`. Never a +coarser join. A person edits this grid, so keep it under ~2,000 rows — more +keys than that means aggregating first — and inside the table column budget +(`SKILL.md`) show the key, the editables, and the two numbers that justify an +edit. Anything else pushes the editable a reader came for off-screen. +Three calls: the opening (call 1) commits the base; this fence +is call 2 against the returned id — the grid's source chain must hold an +element from an earlier call: the base (grouped or filtered tables over it +are fine in the same call), or for a `join` the joined table itself +(`blocks/forecast.md`, call 1) — opening with the re-fetch below; the seed +`update_rows` at the end is the step 5 call, after `lt = plan_page.get_element('')` +narrowed with `isinstance(lt, LinkedInputTable)` and its columns narrowed as +the bullets say. + +```python +# the token block from SKILL.md goes above this — every call opens with it +data_page = sigma.workbook.get_page('') # ids from call 1's state dump +plan_page = sigma.workbook.get_page('') +base = data_page.get_element('') +if not (isinstance(base, LevelTable) and isinstance(plan_page, Page)): + raise RuntimeError('re-fetch') # get_page/get_element return base types +src = base.to_source() +plan_base = data_page.add_table(src) # one row per Product Name +plan_base.set_name('Plan baseline') +plan_base.set_frame(1, 13, 24, 12) +g = plan_base.set_groupings([plan_base.get_column_by_name('')])[0] +g.set_calculation('Baseline revenue', sigma.formula.sum(sigma.formula.multiply(plan_base.get_column_by_name('Price'), plan_base.get_column_by_name('Quantity')))) + +lt = sec.add_linked_input_table(plan_base.to_source(), lookup_columns={'': 'Product'}, source_columns={'Baseline revenue': 'Baseline'}) +lt.set_name('Plan grid') +lt.set_edit_mode('published-all-users') +lt.set_frame(1, 3, 24, 16) +l_adj = lt.add_editable_column('Adjustment %', 'number') +l_ovr = lt.add_editable_column('Override', 'number') +l_why = lt.add_editable_column('Rationale', 'text') +l_base = lt.get_column_by_name('Baseline') +l_plan = lt.add_formula_column('Plan', sigma.formula.coalesce(l_ovr, sigma.formula.multiply(l_base, + sigma.formula.add(sigma.formula.const_num(1), sigma.formula.divide(sigma.formula.number(sigma.formula.coalesce(l_adj, sigma.formula.const_num(0))), sigma.formula.const_num(100)))))) +l_delta = lt.add_formula_column('Delta', sigma.formula.subtract(l_plan, l_base)) +pk = lt.list_primary_key_columns()[0] # the key every action targets — a linked table has no row_id +lt.set_frozen_column(lt.get_column_by_name('Product')) +lt.set_summary_bar(False) +lts = lt.table_style() # then the style calls of the empty table +l_base.set_format('currency_si', decimals=1, currency='$') # and Plan, Delta the same +lt.add_conditional_format(l_delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +lt.add_conditional_format(l_delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +# step 5 is its own call +lt.update_rows([ # step 5 call: seed 3 keys so Plan and Delta read + ({lt.get_column_by_name('Product'): ''}, {l_adj: 5, l_why: 'Demand up in Q3'}), + ({lt.get_column_by_name('Product'): ''}, {l_adj: -3, l_why: 'Supplier constraint'}), + ({lt.get_column_by_name('Product'): ''}, {l_ovr: 60000, l_why: 'New channel'}), +]) +``` + +- If either `add_input_table` or `add_linked_input_table` raises that the + connection cannot host input tables, or names several writeback schemas: + both take `writeback_schema_id=''`, so ask the user which and resubmit + — never conclude the app cannot be built. A **linked** table over a + `sigma.data.source.join` can lose its connection even when both sides share + one; re-base it on a single-table source first. +- Handles from `add_*_column` are what seed dicts and action effects take. A + column fetched later with `get_column_by_name` is a plain `Column` — narrow + it first: `if isinstance(c, LiteralEditableColumn)` / `SingleSelectEditableColumn`. +- One `upsert_rows` / `update_rows` per table, every seed row in it (≤ 2,000). + Seed dates at noon — `datetime(2026, 9, 12, 12)` — or they render a day + early. `to_source()` feeds KPIs, strips, charts, and the chat, without + hidden or system columns. A control on base columns does not reach an input + table: to filter one, target its own columns (`blocks/queue.md`'s toolbar) + or build a second table off `it.to_source()` on the data page. +- A form takes an empty table only (`blocks/intake.md`); actions write to + both kinds (`blocks/queue.md`). diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md new file mode 100644 index 0000000..948f927 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/insight-ai.md @@ -0,0 +1,34 @@ +# AI-written insight + +Only when the user asks for an AI summary AND the warehouse exposes an AI +function (`ai_complete` or an org UDF; ask the user rather than probing +blind). The card is `blocks/insight.md`'s — only the body binding changes. +Aggregate to a small row set first — a grouped helper table on the data +page, ≤ ~50 rows, grouped as `blocks/cards.md` shows — then bind: + +```python +prompt = sigma.formula.const_text('One executive sentence: revenue vs target, name the biggest driver. Data: ') +rows = sigma.formula.array_agg(sigma.formula.ampersand( + sigma.formula.text(base.get_column_by_name('')), + sigma.formula.ampersand(sigma.formula.const_text(': '), sigma.formula.text(base.get_column_by_name('Revenue'))))) +summary = sigma.formula.call_text('ai_complete', sigma.formula.const_text(''), + sigma.formula.ampersand(prompt, sigma.formula.text(rows))) +``` + +and use `formulas={'Summary': summary}` with body `{{Summary}}`. An +error string where the sentence should be means the function name or +model isn't available on this warehouse — fall back to the hand-written +body rather than retrying blind. + +The prompt decides whether the sentence reads like the page or like a +chatbot. Order it: **length cap first** ("Return your response as 1–2 +sentences."), the role, one `Ex:` sentence showing the register wanted, the +data as `#KEY: ` sections joined with `ampersand`, then an explicit "no +markdown, no asterisks". Serialize a column with +`sigma.formula.list_agg(sigma.formula.text(col), ', ')`, and strip the +reply's quote artifacts with `sigma.formula.replace(summary, +sigma.formula.const_text('"'), sigma.formula.const_text(''))`. + +Frame the output as ordinary insight — the card keeps a human-authored +label ("WHAT MOVED", "RECOMMENDED OVERRIDES") with a small `PRIMARY` glyph +(`⍟`) opening it. Never label a card "AI". diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md new file mode 100644 index 0000000..d2297bb --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/insight.md @@ -0,0 +1,31 @@ +# Insight callout + +One `TINT` strip that says what moved, with live values woven into the sentence. Place it under the hero or the KPI row; write real synthesis, not a metric restated. + +```python +note = page.add_container() +note.set_frame(1, y, 24, 3) +note.set_column_density('high') +note.set_spacing('small') +note.set_background_color(TINT) +note.set_border(sigma.border.solid(width=1, color=EDGE, radius='round')) +nl = note.add_text('

What moved

') +nl.set_frame(1, 1, 24, 1) +rev = sigma.formula.sum(base.get_column_by_name('Revenue')) +tgt = sigma.formula.sum(base.get_column_by_name('Target')) +usd = sigma.formula.const_text('$') +mm = sigma.formula.const_num(1000000) +nb = note.add_text( + '

The quarter stands at **{{Total revenue}}** against a **{{Target}}** plan — a **{{Gap}}** gap, and carries most of it. ; take it to .

', + formulas={ # every value pre-formatted (blocks/formulas.md) — bound bare, sum() renders 287243281.43 + 'Total revenue': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(rev, mm), 1)), sigma.formula.const_text('M'))), + 'Target': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(tgt, mm), 1)), sigma.formula.const_text('M'))), + 'Gap': sigma.formula.ampersand(usd, sigma.formula.ampersand(sigma.formula.text(sigma.formula.round(sigma.formula.divide(sigma.formula.subtract(tgt, rev), mm), 1)), sigma.formula.const_text('M'))), + }) +nb.set_frame(1, 2, 24, 2) +nb.set_vertical_align('middle') +``` + +- Single string literal only — adjacent literals are forbidden on this surface; build long copy as one literal or with explicit `+`. +- All `formulas=` bind **`base`'s** columns; a placeholder name is free-form and may repeat a column name (only `(formula, name)` calcs must avoid collisions). +- A one-sentence body fits `nb.set_frame(1, 2, 24, 1)` with card height 2; the two-row body shown holds the full 2–4 sentence synthesis. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md new file mode 100644 index 0000000..546b5d1 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/intake.md @@ -0,0 +1,56 @@ +# Intake app — form beside the log + +The reader logs, submits, requests, tracks, enters. Pages **Log · Overview** +under the sidebar rail (`blocks/sidebar.md`). + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Log | title + dynamic chip `{{n}} pending` | `blocks/title.md` | rows 1–4 | +| Log | left card: the form | fence below | `(1, 5, 8, H)` — H = 5 × fields + 3 | +| Log | right card: the input table, seeded | `blocks/input-table.md` empty table, `set_frame(1, 3, 24, H - 2)` inside the card | `(9, 5, 16, H)` | +| Overview | over `it.to_source()`: KPI row 24·5 (count, open, due this month) → Pair 14/10 14: trend by `Needed by` · stacked by status → table 24·14. A fresh log spans one or two periods, so the trend has no marks to draw: until it covers 3+, drop that band and take the composition full width. An ask naming on-time or outstanding filing also gets a roster band — the entity spine left-joined to `it.to_source()` with a `Filed` flag, never a typed cohort size | `blocks/kpi-row.md` · `blocks/chart-trend.md` · `blocks/chart-composition.md` · `blocks/table.md` | rows 5+ | + +Build the input table (its card is the right column) before the form: + +```python +card = log_page.add_container() # left column, a card because it holds copy +card.set_frame(1, 5, 8, 28) # 5 x fields + 3, so 5 fields → 28 +card.set_column_density('high') +card.set_spacing('small') +card.set_background_color('#ffffff') +card.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +form = card.add_form(it) # one field per editable column; submit appends the row +form.set_frame(1, 1, 24, 28) # the form is the card's only child: same H, no trailing row +form.set_title('New request') +form.set_description('Say what you need and when; approvals land on the right.') +form.set_submit_label('Save request') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_title_font_size(20) +form.set_background_color(None) +form.get_field_by_name('Needed by').set_default(sigma.formula.today()) +f_status = form.get_field_by_name('Status') +if isinstance(f_status, SelectFormField): # a select column arrives as radio buttons + f_status.set_presentation('dropdown') + f_status.set_default('Pending') +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it)]) # the built-in insert stays +saved.set_success_toast(title='Request saved') +``` + +- **6 or more fields goes in a modal, no exceptions** — inline, the form + outgrows the fold and the Save button falls off the card. It moves behind + a `+ New ` button in the title row + (`blocks/wizard.md`, Form modal), and the input-table card takes the full + `(1, 5, 24, H)`. +- The input table **is** the base: no warehouse source, no base table on + the data page (the opening's `base` and `src` lines drop out; `data_page` + stays for helper tables) — unless the ask reconciles what people type against a + source ("where it doesn't line up with what we shipped, flag the gap"), in + which case that page is a **linked** table over the source + (`blocks/input-table.md`, Linked) and the gap is a formula column, not a + second thing to type — `src = it.to_source()` after the seed rows, and the + Overview's KPIs, charts, and table are built on it exactly as a + dashboard's are. +- A survey or questionnaire ask keeps this shape; take question wording from + `sdk://skills/survey/SKILL.md` and nothing else from it. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md new file mode 100644 index 0000000..06d5856 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/kpi-row.md @@ -0,0 +1,82 @@ +# KPI row + +Band of 3–5 KPIs — the scoreboard under the title. Widths by count — 3: `8/8/8` at x `1,9,17` · 4: `6×4` at `1,7,13,19` · 5: `5/5/5/5/4` at `1,6,11,16,21`. +All KPIs in a row share one height: 5 rows (with or without a comparison), 10 with a sparkline — and the band is exactly that tall. SKILL.md's Dashboards row names which variant below a page takes. + +```python +kpis = page.add_container() +kpis.set_frame(1, y, 24, 5) +kpis.set_column_density('high') +kpis.set_spacing('small') +kpis.set_background_color(None) # structural: the KPIs read straight on the canvas — the cards below carry the lines +kpis.set_border(None) +kpis.set_padding(False) # labels align with the page title's left edge + +k1 = kpis.add_kpi(src) +k1.set_frame(1, 1, 8, 5) +k1.set_name('Revenue') # the KPI's own title — never a text element above it +k1.set_value((sigma.formula.sum(k1.get_column_by_name('Revenue')), 'Total revenue')) +k1.get_column_by_name('Total revenue').set_format('currency_si', decimals=1, currency='$') +k1.title().set_color(MUTED) +k1.title().set_font_size(14) +k1.title().set_bold(False) +k1.value_style().set_color(INK) +k1.value_style().set_font_size(24) +k1.value_style().set_bold(True) +k1.layout().set_anchor('left') +k1.layout().set_vertical_anchor('top') # a KPI without a comparison otherwise floats to the middle +k1.set_background_color(None) +k1.set_padding(False) +k1.set_comparison((sigma.formula.sum(k1.get_column_by_name('Target')), 'plan')) # renders "vs plan" — the vs is added for you; the name is a calc column, so never a source column's name in any case +k1.comparison_style().set_direction('higher') +k1.comparison_style().set_good_color('#008236') +k1.comparison_style().set_bad_color('#c10007') +k1.comparison_style().set_label_color(MUTED) +``` + +- Next KPIs: build each one the long way — `k2 = kpis.add_kpi(src)`, then the same `set_frame`, `set_name`, `set_value((formula, ''))`, format, and style calls. `k1.duplicate()` looks cheaper and is not: it drops the copy one row *below* the original, past the band's inner grid, and the container keeps the doubled height — re-asserting `kpis.set_frame(...)` afterwards does not shrink it back. +- **Percent**: format `('percent', decimals=1)`; a rate is `sigma.formula.divide(sigma.formula.sum_if(col, cond), sigma.formula.sum(col2))`. +- **Count**: `sigma.formula.count(col)` / `count_distinct(col)`, format `('number', decimals=0)`. A count with nothing to compare against still takes context — the prior-period timeline below, or the band's status sentence. Never `set_comparison(None)`: a label and a bare figure tell the reader nothing about whether it is good, and a KPI without context fills two rows of a five-row band, leaving a visible gap under it. +- **Prior-period comparison** (needs a date): the timeline is the date + **truncated to the comparison grain** — `km = k1.add_column('Trend month', sigma.formula.date_trunc('month', k1.get_column_by_name('')))` + · `t = k1.trend()` · `t.set_timeline(km)` · `t.set_visible(False)` · then + `k1.set_comparison('month')` and no `set_label` (the label writes itself: + "Sep vs Aug, 2026"). The raw date column instead renders one day's value + under a timestamp label. With a timeline the value **is the latest + period's**, not the total — a partial current period reads as a drop, so + when step 3's max date sits mid-period, compare to a column instead. A + judged measure gets `set_comparison` + direction. +- **Sparkline** (10-row KPI): the same truncated timeline with the trend left + visible — `t.set_shape('area')` · `t.set_area_style('gradient')` · + `t.set_value_color(SERIES)` — `SERIES`, not `PRIMARY`; the value reads the + latest period, the line the history behind it. +- **Stat cards** — when each KPI carries its own one-line reading: one + card per KPI instead of the shared band, `6` wide at x `1,7,13,19`, + height 6 — the section-card calls, the KPI above at `(1, 1, 24, 4)`, and + a `MUTED` `p-small` line at `(1, 5, 24, 2)` saying what the number means + ("Trailing month against the one before."). Never mix the two shapes in + one band. Each card repeats the `k1` calls under its own container + handle — never `duplicate()` across cards. +- **Focus tile** (at most one per page) — a KPI reversed on `PRIMARY` + so one metric carries the eye. It is its own element on + the page beside a 17-wide band, at `(18, y, 7, 10)` with a sparkline, not + inside the KPI band: `set_background_color(PRIMARY)` + · `set_border(sigma.border.solid(radius='round'))` (no line — a loud surface) + · value `#ffffff` at 30 · title `EDGE` (muted on the fill — ink and body + go unreadable on it). A sparkline on the tile takes + `t.set_value_color('#ffffff')`. Everything else is the KPI above, minus + `set_padding(False)` — the tile is its own surface, so it keeps its padding. + A subline under the title has to be a text element inside the same + structural wrapper: `description()` exists on tables and charts, **not** + on KPI. +- **Strip variant** — the white section card comes back (the dividers need a + surface), so the padding does too: drop `kpis.set_padding(False)` and each + KPI's, or the first value sits hard against the card's border. Hairline + dividers instead of gaps: KPIs 6 + wide at x `1,8,15`, with `d = kpis.add_divider('vertical')` · + `d.set_frame(7, 1, 1, 5)` · `d.set_color(HAIRLINE)` between them (and at + x 14). **With a status sentence**, the KPIs narrow to 4 at x `1,6,11` + (dividers at x 5, 10, 15) and the sentence takes `(16, 1, 9, 5)` — + `st = kpis.add_text(...)`, `set_vertical_align('middle')`, a `MUTED` + `p-small` with its values bolded — plus, on an app only, a small action + button in its last columns: a scoreboard that ends in the next step. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md new file mode 100644 index 0000000..b69a2ed --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/pivot.md @@ -0,0 +1,66 @@ +# Pivot card + +A measure by two dimensions — the plan-grid / heatmap shape. A pivot renders +its name as a title by default — so let that title **be** the card label: set +it in sentence case and add no label text to the wrapper. Unlike a table, do **not** +`title_style().set_visible(False)` here; the title is the label. +(`set_name('')` blanks it if a design truly needs it gone.) + +```python +piv = sec.add_pivot_table(src) +piv.set_frame(1, 1, 24, 14) # body (1, 1, 24, H), no label rows; a heatmap card is 14+ columns wide, H14 for five rows +piv.set_name('') +pm = piv.add_column('Pivot month', sigma.formula.date_trunc('month', piv.get_column_by_name(''))) +pm.set_format_pattern('%b') +piv.add_pivot_row(piv.get_column_by_name('')) +piv.add_pivot_column(pm) +piv.add_pivot_value(piv.get_column_by_name('Amount')) # auto-aggregated — and renamed 'Sum of Amount' from here on +pts = piv.table_style() +pts.set_preset('presentation') +for slot in [pts.header_style(), pts.column_header_style(), pts.row_header_style(), pts.cell_style()]: + slot.set_background_color('#ffffff') +piv.set_background_color(None) +piv.set_padding(False) +``` + +- Two row dimensions nest broad→narrow (`add_pivot_row` twice, e.g. + Section then Line Item). +- A source column used as a value is renamed `Sum of ` — look it up + by that name afterwards (formats, conditional formats); a column the + program added with `add_column` keeps its name. +- Value-driven cell color: + `piv.add_conditional_format(piv.get_column_by_name('Sum of Amount'), sigma.conditionalFormat.color_scale([TINT, SERIES]))` + turns it into a heatmap — the `TINT → SERIES` ramp only, never a rainbow; + add `piv.set_totals_default_display(show_subtotals=False, show_grand_totals=False)` + so the totals column does not crowd the cells. +- Column-dimension cardinality decides width: ~12 monthly columns fit; + past that, truncate coarser or filter the range. + +## Rollup grid — no column dimension + +The plan-review shape: nested rows, many value columns, and subtotals you +get by collapsing. Skip `add_pivot_column` entirely. + +```python +piv.add_pivot_row(piv.get_column_by_name('
')) # broad +piv.add_pivot_row(piv.get_column_by_name('')) # narrow +piv.set_totals_default_display(show_subtotals=True, show_grand_totals=True) +piv.set_frozen_column(piv.get_column_by_name('
')) +piv.rename_total_label(piv.get_column_by_name('
'), 'FY total') +piv.collapse_rows(piv.get_column_by_name('')) # opens rolled up +``` + +- **Semantics come from formula conditional formats**, not a color scale — + see `blocks/table.md` for the call, which takes a `sigma.formula` object. + They bold the subtotal rows the data already carries + (`sigma.formula.in_(piv.get_column_by_name('
'), sigma.formula.const_text('Total revenue'), sigma.formula.const_text('Gross profit'))`, + no background), and they shade the settled half of a period grid (`closed`, + defined below → background `HAIRLINE`) so the reader sees where the past + ends. A subtotal the data does *not* carry — gross profit, EBITDA, anything + computed across sections — no pivot can produce: that is + `blocks/statement.md`. +- A period grid can carry **one blended measure** rather than two — actuals + for closed periods, plan for open ones, in one column the shading already + explains. Add it as the single pivot value: + `closed = sigma.formula.lte(pm, sigma.formula.date_trunc('month', sigma.formula.today()))` + then `piv.add_column('Plan or actual', sigma.formula.if_(closed, piv.get_column_by_name('Actual'), piv.get_column_by_name('Plan')))`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md new file mode 100644 index 0000000..b5f59ad --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/plan-grid.md @@ -0,0 +1,49 @@ +# Plan grid app — edit a plan against a baseline + +The reader edits a forecast, budget, plan, headcount, quota, territory. +Pages **Overview · Plan · Review** under the sidebar rail +(`blocks/sidebar.md`). The grid is the page: +a linked table over the baseline at plan grain, editable overrides beside +the computed plan and delta. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Overview | title: greeting + eyebrow naming the period | `blocks/title.md` | rows 1–4 | +| Overview | next-action hero — statement + one button `Open the plan →` wired `navigate_to(plan_page)` | `blocks/hero.md` statement + CTA | `(1, 5, 24, 5)` | +| Overview | KPI row over `lt.to_source()` — calcs `'Total plan'` · `'Total baseline'` · `'Net delta'` | `blocks/kpi-row.md` | `(1, 10, 24, 5)` | +| Overview | pending changes: cards over `pend = data_page.add_table(lt.to_source())` with `ed = pend.add_column('Edited', sigma.formula.if_(sigma.formula.is_not_null(pend.get_column_by_name('Rationale')), sigma.formula.const_num(1), sigma.formula.const_num(0)))` · `ed.set_filter(sigma.filter.values([1]))` (a values filter rejects booleans); family, rationale, and pre-formatted Plan · Delta text; `set_no_data_label('NOTHING PENDING')` | `blocks/cards.md` | `(1, 15, 24, 10)`. Seed `Rationale` on ≤6 rows — this card is the rows a person touched, so a rationale on every row empties it of meaning. Quotas may be seeded on all of them | +| Plan | title + `Submit for review` primary button at cols 19–24 | `blocks/title.md` · `blocks/buttons.md` | rows 1–4 | +| Plan | context strip — edits count, net delta; drop its `sel` and `btn` (the sidebar strip's `pick` is the picker) | `blocks/context-strip.md` | `(1, 5, 24, 2)` | +| Plan | the grid, seeded | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Plan | compare strip Baseline · Plan · Change | `blocks/compare-strip.md` over `lt.to_source()` | `(1, 25, 24, 2)` | +| Review | title | `blocks/title.md` | rows 1–4 | +| Review | decision log `log` — Plan, Status pills, Note, system columns; 2 seed rows. **The decision is made inline**, in those editable columns — no approval panel beside it: `log`'s only row selector is its generated `i_id`, and nothing can hand a picker an id a reader would recognise (`blocks/detail.md`) | `blocks/input-table.md` empty | `(1, 5, 24, 12)` | +| Review | decisions by outcome, with the status sentence | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 17, 24, 5)` | + +Build order: Plan page (`sec`, `lt`) → Overview → Review (`log`, columns +`g_plan` text · `g_status` single-select Submitted · Approved · Needs +changes · `g_note` text) → then the `submit` wiring, whose effect needs `log`: + +```python +submit = plan_page.add_button('Submit for review') # primary button (blocks/buttons.md), cols 19–24 of the title band +submit.set_frame(19, 2, 6, 2) +submit.set_align('right') +submit.set_appearance('solid') +submit.set_shape('round') +submit.set_fill_color(PRIMARY) +submit.set_font_color('#ffffff') +sent = submit.on_click('submit', [ + sigma.action.inputTableEffects.insert_row(log, {g_plan: '', g_status: 'Submitted', g_note: 'Sent from the plan page'}), + sigma.action.refreshElementEffect.refresh_element(log), +]) +sent.set_success_toast(title='Submitted for review', message='{{n}} edits · net {{d}}', formulas={ + 'n': sigma.formula.count_if(sigma.formula.is_not_null(lt.get_column_by_name('Rationale'))), + 'd': sigma.formula.sum(lt.get_column_by_name('Delta')), +}) +``` + +- A period in the ask (by month, by quarter) is `blocks/forecast.md`. +- `Adjustment %` is per row, not compounding. A column whose name contains + cost, expense, or spend: favorable delta is negative — swap the good/bad + tints on its Delta format. +- `Status` values on `log`: Submitted · Approved · Needs changes. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md new file mode 100644 index 0000000..958ffd0 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/queue.md @@ -0,0 +1,104 @@ +# Review queue app — decide record by record + +The reader approves, rejects, triages, resolves, assigns. Pages **Queue · +Done** under the sidebar rail (`blocks/sidebar.md`). The queue is the page: a linked input table over the records, +one row per key, with the decision editable inline and a drawer for the +focused decision. + +| Page | Band | Block | Frame (the card) | +|---|---|---|---| +| Queue | title + dynamic chip `{{n}} to decide` — `sigma.formula.count_if(sigma.formula.is_null(l_dec))` | `blocks/title.md` | rows 1–4 | +| Queue | toolbar: decision segmented (no default) · owner list · search — targets the queue's own columns, ending at column 18 so the record picker takes 19–24 | `blocks/filters.md` toolbar | `(1, 5, 24, 2)` | +| Queue | the queue: linked table over a data-page table that keeps only open records — `qsrc.get_column_by_name('').set_filter(sigma.filter.values(['']))` and `qsrc.get_column_by_name('').set_filter(sigma.filter.date_relative('last', 7, 'day'))` — keyed on the record id, `source_columns` = name, amount, date from `describe` (max 6), editable `Decision` pills · `Owner` · `Note`, formula `Age (days)`; step 5 seeds 2 rows with a Decision | `blocks/input-table.md` linked | `(1, 7, 24, 18)` | +| Queue | decision drawer, opened by the record picker (`blocks/detail.md` — a click cannot say which record) | fence below | overlay | +| Done | audit log `log` — Record, Decision, Note, `created_at`, `created_by` left visible; 2 seed rows | `blocks/input-table.md` empty | `(1, 5, 24, 6)` — `blocks/input-table.md`'s height arithmetic on its seed count, growing with the log, never a reserved 18 | +| Done | decisions by outcome, with the status sentence — the log has no comparison to offer | `blocks/kpi-row.md` strip variant over `log.to_source()` | `(1, 11, 24, 5)` | + +Before the drawer, on the data page: `qsrc = data_page.add_table(src)` · +`qsrc.set_name('Open records')` · `qsrc.set_frame(1, 13, 24, 12)` · the two +filters above · `qpick = data_page.add_table(qsrc.to_source())` · +`qpick.set_name('Record picker')` · `qpick.set_frame(1, 26, 24, 6)` — the +picker's option source, nothing downstream of it; then `lt = sec.add_linked_input_table(qsrc.to_source(), lookup_columns={'': ''}, source_columns={...})` +with `l_dec = lt.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` +(no decision = null = the queue), `l_own = lt.add_editable_column('Owner', 'text')`, +`pk = lt.list_primary_key_columns()[0]` — `pk` is the drawer's whole wiring: +the card's key, the row it selects, and the row the buttons write to. It +is also what the picker lists, so take `lookup_columns` from the column a +reader recognises wherever one is unique (`blocks/detail.md`); +on the Done page's card, `log = done_sec.add_input_table('')` · +`g_rec = log.add_editable_column('Record', 'text')` · +`g_dec = log.add_single_select_column('Decision', ['Approved', 'Rejected', 'Needs changes'])` · +`g_note = log.add_editable_column('Note', 'text')`, the rest as +`blocks/input-table.md`'s empty table. Above the fence, `money` = +`blocks/formulas.md`'s money over `lt.get_column_by_name('')`. + +```python +drawer = sigma.workbook.add_drawer('Decide') +drawer.set_width('medium') +drawer.set_header(title='Decide', show_close_icon=True) +card = drawer.add_single_row_container(lt.to_source(), key_column=pk, initial_value='') +card.set_frame(1, 1, 12, 10) # the drawer's whole body — a single row container is a container, so the note sits in it +card.set_column_density('medium') +card.set_padding(False) +card.set_gap(False) +name = card.add_text('

**{{Name}}**

', formulas={'Name': pk}) +name.set_frame(1, 1, 12, 2) +facts = card.add_value_list(fields={ # lt is already one row per key, so its own handles bind bare in here — no grouped helper, no aggregate + 'Amount': money, # a bare number column would bind unformatted + 'Owner': l_own, +}) +facts.set_layout('separated') +facts.set_background_color(None) +facts.set_frame(1, 3, 12, 3) +note = sigma.control.add_text_area_control(card, placeholder='Why?') +note.set_name('Note') +note.set_frame(1, 6, 12, 3) +approve = card.add_button('Approve') # primary: PRIMARY (blocks/buttons.md) +approve.set_frame(1, 9, 4, 2) +approve.set_appearance('solid') +approve.set_shape('round') +approve.set_fill_color(PRIMARY) +approve.set_font_color('#ffffff') +reject = card.add_button('Reject') # neutral fill +reject.set_frame(5, 9, 4, 2) +reject.set_appearance('solid') +reject.set_shape('round') +reject.set_fill_color(FILL) +reject.set_font_color(BODY) +ok = approve.on_click('approve', [ # write → log → refresh → close + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Approved'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Approved', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +ok.set_success_toast(title='Approved', message='{{r}} approved', formulas={'r': pk}) +no = reject.on_click('reject', [ + sigma.action.inputTableEffects.update_rows(lt, {pk: sigma.formula.text(pk)}, {l_dec: 'Rejected'}), + sigma.action.inputTableEffects.insert_row(log, {g_rec: sigma.formula.text(pk), g_dec: 'Rejected', g_note: note}), + sigma.action.refreshElementEffect.refresh_element(lt), + sigma.action.overlayEffects.close_overlay(), +]) +no.set_success_toast(title='Rejected') +drawer.on_close('reset', [sigma.action.clearControlEffect.clear_control(note)]) +pick = sigma.control.add_list_control(bar, target=qpick.get_column_by_name(''), allows_multi=False, allows_search=True) +pick.set_name('') # bar = the toolbar; qpick is a picker-only table, since a control listing the queue's own ids would narrow the queue +pick.set_frame(19, 1, 6, 2) # the toolbar's last slot +pick.on_change('open', [ + sigma.action.setSingleRowViewEffect.select_row(card, pick), + sigma.action.overlayEffects.open_overlay(drawer), +]) +``` + +- `Age (days)` is `blocks/formulas.md`'s `age_days`, bound with + `lt.add_formula_column('Age (days)', ...)` over `lt`'s date column. +- Cap the queue at the source, never on the linked table: the status and + relative-date filters above; a date literal (`const_date`) fails at + evaluation, a relative filter does not. Never key on a status or owner — + those change. The status segmented control takes no `value=`: rows with + no decision yet are the queue. +- The decision buttons go **inside** `card`, never beside it: a column + reference resolves against the element hosting it, so `pk` is the shown + record's id in there and the literal `Multiple values` anywhere else. That + is what the actions target — there is no control holding the record. + Third decision (`Request changes`) = the neutral button again at + `(9, 9, 4, 2)`, writing `'Needs changes'`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md new file mode 100644 index 0000000..fa2e1ad --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/sidebar.md @@ -0,0 +1,110 @@ +# Sidebar rail + strip + +The app shell, shared by every page: a white fixed **rail** down the left — +lockup, vertical navigation, who is signed in — and a two-row **strip** +across the top carrying the one workbook-wide picker and the `Assistant`. +Use it when the shell table's row is *acts* or *reads across three or more +views*. The rail holds **destinations only**; anything that is not a page +goes on the strip. Pages under this shell skip `blocks/header.md` — the +strip carries no navigation, and two navs is the one thing never built. + +## Rail — identity and destinations + +```python +# panel calls live on Page, which add_page returns — create the rail on one +# page and attach it to each of the others (blocks/header.md) +side = plan_page.add_sidebar() +side.set_background_color('#ffffff') +side.set_border_style('line') +side.set_scroll_behavior('fixed') +side.set_width('medium') +lockup = side.add_text('****') # **Revenue planning** — the lockup rule in blocks/header.md +lockup.set_frame(1, 1, 6, 2) +lockup.set_vertical_align('middle') +tagline = side.add_text('

') +tagline.set_frame(1, 3, 6, 1) +group = side.add_text('

Main

') +group.set_frame(1, 5, 6, 1) +nav = side.add_navigation() +nav.set_frame(1, 6, 6, 8) # 2.5 rows per visible page, rounded up — the bullets below +nav.set_orientation('vertical') +nav.set_style('pill') +nav.set_alignment('left') +nav.set_text_color(MUTED) +nav.set_selected_color(PRIMARY) +foot = side.add_divider() # who is signed in — the rail's last rows +foot.set_frame(1, 20, 6, 1) +foot.set_color(HAIRLINE) +who = side.add_text('

**{{Who}}**
{{Mail}}

', formulas={'Who': sigma.formula.current_user_full_name(), 'Mail': sigma.formula.current_user_email()}) +who.set_frame(1, 21, 6, 2) + +home.set_sidebar(side) # every page the rail covers +review_page.set_sidebar(side) +``` + +## Strip — scope and the Assistant + +The rail's companion on the same pages, flush right. Controls and buttons +never sit on the panel itself — on the container `hc`. + +```python +strip = plan_page.add_header() # the page the rail was created on +strip.set_background_color('#ffffff') +strip.set_border_style('line') +strip.set_scroll_behavior('fixed') +hc = strip.add_container() # structural, but it keeps its gap — the picker and the trigger must not touch +hc.set_frame(13, 1, 12, 2) +hc.set_column_density('high') # 24 inner columns +hc.set_padding(False) +hc.set_spacing('small') +hc.set_background_color(None) +hc.set_border(None) +pick = sigma.control.add_list_control(hc, target=base.get_column_by_name(''), allows_multi=False) +pick.set_name('') # the control's own label names what it scopes — no text label beside it, and never a blank name (blocks/filters.md) +pick.set_frame(1, 1, 15, 2) # only when describe returned such a column; else drop it, shrink hc to (19, 1, 6, 2), and give ask (1, 2, 24, 1) +ai = sigma.workbook.add_popover(hc, 'Assistant') # fill it per blocks/chat.md +ask = ai.trigger() # a popover has no action to open it — its trigger is the button +ask.set_text('Assistant') +ask.set_frame(16, 2, 9, 1) # row 2 — level with the picker's input, not its label (blocks/filters.md) +ask.set_appearance('solid') # the soft button of blocks/buttons.md — an action, never nav ink +ask.set_shape('round') +ask.set_button_size('small') +ask.set_align('right') +ask.set_fill_color(TINT) +ask.set_font_color(PRIMARY) + +home.set_header(strip) # the rail's pages, one for one +review_page.set_header(strip) +sigma.workbook.set_page_tabs_hidden_in_view_mode(True) +``` + +- Rail and strip attach to the **same** pages — every `set_sidebar` gets a + `set_header`. A page with one and not the other loses its nav or its scope. +- The sidebar grid is a **fixed 6 columns** whatever its pixel width; + `set_width('small'|'medium'|'large')` changes pixels only — 200/260/400px. + It grows as tall as its content — stack top-down, no bottom anchoring. The + strip is 24 columns; keep it to the two rows above. +- A vertical nav option is ~52px, so the rail's nav takes **2.5 rows per + visible page, rounded up** — 3 pages 8, 4 pages 10, 6 pages 15. Frame it + short and the pages past the fold do not clip, they hide behind a scroll + nobody goes looking for, so the app reads as though it had fewer pages — + count the pages and take the number above, never the 6 that fits three. + `set_option_size('small')` buys about one more page per 12 rows. The nav + can grow to row 19 before `foot` and `who` move down with it. +- A panel must be **assigned to a page before elements go in** — creating it + via `page.add_sidebar()` / `page.add_header()` (as above) satisfies that; a + panel has no `set_padding`/`set_border`/`set_spacing` of its own, its inset + is theme-fixed. Elements can be added to it at any point in the program. +- The rail's text is `INK` and `MUTED`; the nav marks the current page in + `PRIMARY` via `set_selected_color`. `ask` is the shell's only tinted + surface — nothing else on either panel carries the hue. +- Title blocks start at row 1, body `y` 5. Page filters stay on the page — + the strip's picker re-scopes every page. Hide workbook tabs only once the + nav reaches every visible page (auto nav does). +- Six or more pages: a second group label one row under the first nav and a + `manual`-mode navigation below that for the pages that are not the work — + `Resources` over README and Data, so `Main` lists only where the reader + goes to work. Size each nav to its own page count and move `foot` and + `who` below both. `manual` is **seed-and-prune**: it starts from the + visible pages, and an option can only be `delete()`d — there is no call + that adds, relabels, or retargets one. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md new file mode 100644 index 0000000..217557b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/statement.md @@ -0,0 +1,167 @@ +# Statement grid (P&L) + +A P&L is not a table of the data — it is an **ordered list of lines**, some +read from accounts and some computed across them (gross profit, operating +income), each landing in a fixed place. No grouping or subtotal setting +produces that: gross profit is revenue *minus* cost of sales, arithmetic across +two sections, not a rollup inside one. So the statement is a **union of small +tables** — one for the detail lines, one per computed line — all sharing five +columns, stacked and then sorted by an ordinal. It is one band, sized to its +lines (SKILL.md's band rule carves it out). + +## Two things step 3 must settle — a wrong answer here is silent + +- **Section and ordinal per account.** The order comes from a number, so + something must map account → section → sort position. A ledger fact table + rarely carries it; the usual shape is a **join** to an account hierarchy on + the account code, taking only its attributes — never a measure, or the + aggregates fan out. With no hierarchy at all, map the sections yourself with + `sigma.formula.switch_` and say so in the report: it is your reading, not the + warehouse's. +- **The sign of costs.** `query` `SUM(amount)` grouped by section. Warehouses + usually store costs **positive**, which is what `Signed` below normalises; if + they are already negative, drop the `neg`. Getting this wrong doesn't error — + it returns a plausible, wrong gross profit. + +## Signed amounts — one rule, top to bottom + +Sign every amount once, and every computed line becomes a plain sum. Because a +signed statement rises when things go well on *every* line, one variance rule +then reads correctly from revenue to net income. Without it you need +per-section polarity rules, and one missed section paints half the statement +backwards. + +## Skeleton + +Five shared columns — `Block` (section, `Subtotal`, or `Ratio`), `Label`, +`Ord`, `Actual`, `Plan`. Every table in the union exposes exactly these names; +`union_sources` matches by name and keeps only what every source has. Name the +calcs for the statement, not the warehouse: if the source itself has an +`Actual` or `Plan` column, the calc silently becomes `Actual (1)` — pick +`Actual $` or rename at the base. + +```python +# 1 · detail lines — one row per statement line +det = data_page.add_table(base.to_source()) +det.set_name('Statement lines') +det.set_frame(1, 14, 24, 12) +d_scn = det.get_column_by_name('') +d_sgn = det.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(det.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(det.get_column_by_name('')), det.get_column_by_name(''))) +d_blk = det.add_column('Block', sigma.formula.text(det.get_column_by_name('
'))) +d_lbl = det.add_column('Label', sigma.formula.text(det.get_column_by_name(''))) +dg = det.set_groupings([[d_blk, d_lbl]])[0] +dg.set_calculation('Ord', sigma.formula.min(det.get_column_by_name(''))) +dg.set_calculation('Actual', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Actual')))) +dg.set_calculation('Plan', sigma.formula.sum_if(d_sgn, sigma.formula.eq(d_scn, sigma.formula.const_text('Budget')))) + +# 2 · one computed line per subtotal — repeat this whole block per the table +# below, changing only the label, the ordinal, and the sections in scope, +# each table 6 rows tall on a 7-row pitch: y = 27, 34, 41, 48, 55 +gp = data_page.add_table(base.to_source()) +gp.set_name('Gross profit') +gp.set_frame(1, 27, 24, 6) +g_scn = gp.get_column_by_name('') +g_sgn = gp.add_column('Signed', sigma.formula.if_( + sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Cost of Goods Sold'), sigma.formula.const_text('Operating Expenses')), + sigma.formula.neg(gp.get_column_by_name('')), gp.get_column_by_name(''))) +g_scope = sigma.formula.in_(gp.get_column_by_name('
'), + sigma.formula.const_text('Revenue'), sigma.formula.const_text('Cost of Goods Sold')) +g_blk = gp.add_column('Block', sigma.formula.const_text('Subtotal')) +g_lbl = gp.add_column('Label', sigma.formula.const_text('Gross profit')) +gg = gp.set_groupings([[g_blk, g_lbl]])[0] +gg.set_calculation('Ord', sigma.formula.const_num(5350)) +gg.set_calculation('Actual', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Actual')))) +gg.set_calculation('Plan', sigma.formula.sum_if(g_sgn, g_scope, sigma.formula.eq(g_scn, sigma.formula.const_text('Budget')))) + +# 3 · the statement — detail source first; its names become the union's names +stmt_src = sigma.data.source.union_sources([ + sigma.data.source.SourceInput(det.to_source(), dg), + sigma.data.source.SourceInput(gp.to_source(), gg), +]) # one SourceInput per computed line +st = sec.add_table(stmt_src) +st.set_frame(1, 3, 24, 21) # statement lines + 2 — small spacing puts a line on the 24px pitch, not SKILL.md's 32; card height + 2 +lbl = st.get_column_by_name('Label') +act = st.get_column_by_name('Actual') +pln = st.get_column_by_name('Plan') +blk = st.get_column_by_name('Block') +var = st.add_column('Var $', sigma.formula.subtract(act, pln)) +pct = st.add_column('Var %', sigma.formula.divide(var, sigma.formula.abs(pln))) +st.set_visible_columns([lbl, act, pln, var, pct]) +st.set_sort([sigma.sort.ascending(st.get_column_by_name('Ord'))]) +act.set_format_pattern('(,.0f') # accounting: negatives in parentheses +pln.set_format_pattern('(,.0f') +var.set_format_pattern('(,.0f') +pct.set_format('percent', decimals=1) +ts = st.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('small') # a statement is dense by convention +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +st.title_style().set_visible(False) +st.set_summary_bar(False) +st.set_background_color(None) +st.set_padding(False) + +st.add_conditional_format(st.list_columns(), sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Subtotal')), background=FILL, bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.less_than(0, text_color='#c10007', bold=True)) +st.add_conditional_format([var, pct], sigma.conditionalFormat.greater_than(0, text_color='#008236')) +# a ratio line's cells reformat themselves — see Ratio lines below +st.add_conditional_format([act, pln], sigma.conditionalFormat.formula( + sigma.formula.eq(blk, sigma.formula.const_text('Ratio')), number_format='percent', format_decimals=1)) +``` + +The computed lines. Take the ordinals from the account ranges step 3 returned +and slot each one just past the block it closes — the numbers below suit a +4xxx revenue / 5xxx cost-of-sales / 6–7xxx operating chart of accounts: + +| Label | `Ord` | Sections in `g_scope` | +|---|---|---| +| Total revenue | 4998 | Revenue | +| Total cost of sales | 5300 | Cost of Goods Sold | +| Gross profit | 5350 | Revenue · Cost of Goods Sold | +| Total operating expenses | 7350 | Operating Expenses | +| Operating income | 8001 | Revenue · Cost of Goods Sold · Operating Expenses | + +## Notes + +- Cost lines read **negative** on a signed statement, which is the accounting + convention — parentheses, not a minus sign, and not red. Red is reserved for + an unfavourable variance, which after signing is simply `Var $ < 0` on every + line: no polarity column, no per-section rules. +- `Var %` divides by `abs(Plan)` so the percentage keeps the sign of the + variance — over a negative cost plan, a plain divide flips it. +- `'(,.0f'` prints no currency symbol, as a statement should: name the units + once in the card subline ("$000s", "USD"), not 90 times in the grid. +- Statement variance takes the good/bad color **as text**, not + `blocks/table.md`'s tint backgrounds: 40 tinted cells reads as an alarm, and + the subtotal band already carries the structure. That band deliberately + borrows the neutral fill `FILL` — `TINT` would read + as a callout, and the settled-period `HAIRLINE` is a step darker. +- **Ratio lines** — gross margin %, opex as a share of revenue — are ordinary + union members whose calc is a quotient of two `sum_if`s, with + `Block = 'Ratio'` and an ordinal just past the line they qualify (5355 after + gross profit). The percent rendering comes from the conditional format + above — a rule's own `number_format`, per `blocks/table.md`. +- A chart beside the statement keeps **statement order**, never value order: + `chart.set_sort_by_calculation('dimension', sigma.formula.min(chart.get_column_by_name('Ord')), ascending=True)`. +- Blank spacer lines between sections are one more union member of constants, + whited out on `Block = 'Spacer'` — a whole table per gap, so only when the + subtotal bands alone do not break the page up. +- **A month-by-month statement**: reach for `blocks/pivot.md`'s rollup grid + first — it spreads months for free. Come back here only when the grid needs + computed lines across sections, and then twelve `sum_if` calcs replace + `Actual`/`Plan` on **every** union member (the union keeps only columns all + its sources have), each blending actuals for closed months with plan for open + ones. Scope to one fiscal year first, since a month index does not know its + year, and shade the settled months per `blocks/pivot.md`. + +A balance sheet or cash-flow statement is this same skeleton with different +sections and ordinals; take their line semantics from +`sdk://skills/balance-sheet/SKILL.md`, and nothing else. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md new file mode 100644 index 0000000..2854897 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/table.md @@ -0,0 +1,88 @@ +# Table card + +Row-level detail — usually a page's last band, full width, inside a +section card. Keep tables for detail; a ranking belongs in +`blocks/chart-ranked.md`. + +```python +tbl = sec.add_table(src) +tbl.set_frame(1, 2, 24, 12) # H = 13; body per SKILL.md's section-card rule (an 18-row card → (1, 2, 24, 17)) +cols = [ + tbl.get_column_by_name(''), + tbl.get_column_by_name(''), + tbl.get_column_by_name('Revenue'), + tbl.get_column_by_name(''), +] +tbl.set_visible_columns(cols) +tbl.set_sort([sigma.sort.descending(tbl.get_column_by_name('Revenue'))]) +ts = tbl.table_style() +ts.set_preset('presentation') +ts.set_grid_lines('horizontal') +ts.set_cell_spacing('medium') +ts.header_style().set_color(MUTED) +for slot in [ts.header_style(), ts.cell_style()]: + slot.set_background_color('#ffffff') +tbl.title_style().set_visible(False) +tbl.set_summary_bar(False) # the row-count strip under the grid; SKILL.md's height rule assumes it off +tbl.set_background_color(None) +tbl.set_padding(False) + +delta = tbl.get_column_by_name('') +tbl.add_conditional_format(delta, sigma.conditionalFormat.greater_than(0, background='#dcfce7', text_color='#008236', bold=True)) +tbl.add_conditional_format(delta, sigma.conditionalFormat.less_than(0, background='#ffe2e2', text_color='#c10007', bold=True)) +``` + +- Every judged column ships value-driven color in the good/bad tints — + never raw green/red, and never color on a column nothing judges. +- **Whole-row verdict** — pass every column, and the rule paints the row: + `tbl.add_conditional_format(tbl.list_columns(), sigma.conditionalFormat.formula(sigma.formula.eq(tbl.get_column_by_name(''), sigma.formula.const_text('At risk')), background='#fef3c6'))` + — the warn fill for a state still open, the bad fill for one already lost. + `conditionalFormat.formula` takes a **`sigma.formula` object**, never a + formula string — a string fails the typecheck. It is the only rule that + compares two columns: + `sigma.formula.lt(tbl.get_column_by_name('Revenue'), tbl.get_column_by_name('Target'))`. +- Every single-color rule also carries `number_format=` / `format_decimals=` / + `format_currency=`, so one rule can reformat the cells it matches — a percent + row inside a currency column, without touching the column's own format. +- Grouped rollup: `g = tbl.set_groupings([tbl.get_column_by_name('')])[0]` + — derive a date grain with `add_column` + `sigma.formula.date_trunc` + first; grouping keys always show. The rolled-up measure is + `g.set_calculation('', sigma.formula.sum(tbl.get_column_by_name('Revenue')))`. + An aggregate in `add_column` instead returns the **row-level** value, not + the group's — it renders numbers, so it reads plausible and is wrong. + `set_visible_columns` takes base-level columns only — a grouping + calculation in the list raises; one row per group with only its + calculations is the pivot rollup (`blocks/pivot.md`). +- **Goals card** — a contained card, 8–12 columns, for targets, quotas, or + budget lines: label + subline with a soft action button on the label row + (`blocks/buttons.md`, `'New goal'` opening the form modal of + `blocks/wizard.md`), then a one-row-per-goal table with a progress bar, + then a one-line muted footer. The table: + ```python + gt = sec.add_table(src) + gt.set_frame(1, 3, 24, T) # T from the table height rule in SKILL.md, on the goal count + g = gt.set_groupings([gt.get_column_by_name('')])[0] + g.set_calculation('Achieved', sigma.formula.sum(gt.get_column_by_name('Revenue'))) + g.set_calculation('Progress', sigma.formula.divide(sigma.formula.sum(gt.get_column_by_name('Revenue')), sigma.formula.sum(gt.get_column_by_name('Target')))) + g.set_collapsed(True) # group rows only + gt.set_visible_columns([]) # an empty list hides every base column; keys and calculations stay + gt.get_column_by_name('Achieved').set_format('currency_si', decimals=1, currency='$') + gt.get_column_by_name('Progress').set_format('percent', decimals=0) + gt.add_conditional_format(gt.get_column_by_name('Progress'), sigma.conditionalFormat.data_bars(SERIES_MID, '#c10007', show_values=True, min=0, max=1)) # mid tone — the percent prints over the bar + ``` + plus the table-style calls above; the soft button `'New goal'` at + `(17, 1, 8, 2)` with the label and subline 16 wide (a frame over them + relocates the subline to the card's bottom); the footer at `(1, T + 3, 24, 2)` binds + `sigma.formula.count_distinct(base.get_column_by_name(''))` — + "{{n}} goals tracked" (a per-goal condition cannot be counted in prose). + Card height H = T + 4 (13 goals → T 19, H 23). As a Briefing's 8-column + side card, T = 10 → H 14 (6 goals, the rest scroll) and no button. +- Number formats per column: + `col.set_format('currency_si', decimals=1, currency='$')` / + `('percent', decimals=1)` / a date's `set_format_pattern('%b %d')`. +- "And then what?" — a table of records on a work surface carries a + picker that opens the record (`blocks/detail.md`), or is deliberately + reference-only; a table nobody can open is the commonest thing missing + from a finished page. A table of records a reader looks things up in also takes + a `contains` search over its name column in the toolbar above + (`blocks/filters.md`). diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md new file mode 100644 index 0000000..42917f3 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/tabs.md @@ -0,0 +1,72 @@ +# Tab switcher + +Two depths of one section — the picture and the numbers behind it (chart ↔ +table), a summary ↔ the rows it totals, a roll-up ↔ its breakdown. The tab +bar is hidden and quiet text buttons on the card's label row drive it, so +the card reads as one surface with a switch rather than as two bands. +Stepping a **flow** rather than swapping a view is the same container with +the steps as tabs (`blocks/wizard.md`). + +```python +tc = sec.add_tabbed_container() +tc.set_frame(1, 2, 24, 13) # row 2 = the card's subline row: drop the subline, or start at y=3 height 12 +tc.set_column_density('high') +tabs = tc.list_tabs() # a new tabbed container has 3 tabs +chart_tab = tabs[0] +chart_tab.set_name('Chart') +table_tab = tabs[1] +table_tab.set_name('Table') +tabs[2].delete() # always account for all three +tc.set_tab_bar_visible(False) # the buttons drive it +tc.set_padding(False) +tc.set_gap(False) + +show_chart = sec.add_button('Chart') # quiet switchers on the label row +show_chart.set_frame(19, 1, 3, 1) +show_chart.set_appearance('text') +show_chart.set_button_size('small') +show_chart.set_align('right') +show_chart.set_font_color(MUTED) +show_chart.set_bold(False) +show_table = sec.add_button('Table') +show_table.set_frame(22, 1, 3, 1) +show_table.set_appearance('text') +show_table.set_button_size('small') +show_table.set_align('right') +show_table.set_font_color(MUTED) +show_table.set_bold(False) +show_chart.on_click('show chart', [sigma.action.selectTabEffect.select_tab(chart_tab)]) +show_table.on_click('show table', [sigma.action.selectTabEffect.select_tab(table_tab)]) +``` + +Fill each tab like a page region (`chart_tab.add_line_chart(src)` …, frames on +its 24-column grid, height ≈ container − 2). + +- Cheap variant — keep the bar, skip the buttons and the three hide calls: + `tc.set_tab_style('button')`, `tc.set_tab_bar_size('small')`, `tc.set_tab_alignment('end')`. +- `select_tab` also takes a `(tc, 'next')` / `(tc, 'previous')` pair — clamped at the ends, no wraparound. +- Elements go into tabs, never the tabbed container itself. +- Switchers are buttons, never a segmented control — its chrome fights the quiet label row; the cheap variant above is the visible-strip route. + +## Tabbed main — the page as two or more full views + +Two or more whole views of one dataset (by region · by product · by stage): +the tabbed container is the page **body** — the wide column of a full-height +split, or full bleed where there is no rail. **It** is the card and carries +the surface; everything inside a tab is structural. Card the tab's contents +instead and the bar floats above a boxed inset, a card inside a card. + +```python +tc = page.add_tabbed_container() +tc.set_frame(6, y, 19, 30) # beside a 5-column rail; (1, y, 24, 30) with none +tc.set_column_density('high') +tc.set_tab_style('button') +tc.set_tab_bar_size('small') +tc.set_background_color('#ffffff') +tc.set_border(sigma.border.solid(width=1, color=HAIRLINE, radius='round')) +``` + +Fill each tab as above — first element at row 1, the bar sitting above the +tab's grid rather than in it, the last ending at `height − 2`. A section +card inside a tab keeps its frames and swaps its fill and border for +`set_padding(False)` + `set_gap(False)` — structural, like everything in a tab. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md new file mode 100644 index 0000000..28a33e7 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/title.md @@ -0,0 +1,91 @@ +# Title block + +Every page opens with language, not metrics: eyebrow, title, one-line +subhead, rule. Rows 3–6 or 1–4 — the shell table's column; the rule +sits directly under the subhead, never a blank row between. + +```python +# build this once per page, on that page's handle (home, scenarios, …); +# the structural wrapper makes the three lines read as one unit. +tb = home.add_container() +tb.set_frame(1, 3, 13, 3) # (1, 1, 13, 3) at rows 1–4 +tb.set_column_density('high') +tb.set_padding(False) +tb.set_gap(False) +tb.set_background_color(None) +tb.set_border(None) +eyebrow = tb.add_text('

') +eyebrow.set_frame(1, 1, 24, 1) +title = tb.add_text('### ') +title.set_frame(1, 2, 24, 1) +sub = tb.add_text('

') +sub.set_frame(1, 3, 24, 1) +rule = home.add_divider() # the rule stays on the page, full width +rule.set_frame(1, 6, 24, 1) # (1, 4, 24, 1) at rows 1–4 +rule.set_color(HAIRLINE) +``` + +## Chip + +A small status tag — beside the title, on a card's label row, or naming a +capability ("LIVE · SNOWFLAKE"). + +```python +chip = home.add_text('

') +chip.set_frame(21, 3, 4, 1) # on the page, beside the wrapper; (21, 1, 4, 1) at rows 1–4 +chip.set_vertical_align('middle') +chip.set_padding(False) +chip.set_background_color(TINT) +chip.set_border(sigma.border.solid(width=1, color=EDGE, radius='pill')) +``` + +- A chip whose meaning is fixed at build time takes the semantic pair + instead — span color and fill `'#008236'` on `'#dcfce7'` (good), + `'#c10007'` on `'#ffe2e2'` (bad), `'#bb4d00'` on `'#fef3c6'` (warn), + border `None`; a chip bound to a column cannot vary its fill, so it + stays `TINT`. + +- Titles are imperatives or noun phrases in sentence case ("Build the + plan", "Pipeline review"); the eyebrow carries period or program context + in CAPS; the subhead says what the reader does here. An app's Overview, + or the first page under a sidebar rail, greets instead: `tb.add_text('### Welcome back, {{Name}}', formulas={'Name': sigma.formula.current_user_first_name()})`. +- **Breadcrumb** (multi-page apps, above the eyebrow or replacing it): + `crumb = home.add_text('

Overview › Scenarios

')` + — trail muted, current page body-ink, never a link hue. +- **Dynamic chip**: a small button with a formula label and no action — + `chip = home.add_button('{{Status}}', formulas={'Status': sigma.formula.max(base.get_column_by_name(''))})` + · solid · pill · small · fill `TINT` · font `PRIMARY` — live status + ("Draft", "Last updated…") beside the title. An app's count chip binds + the input table: `'{{n}} pending'` with + `{'n': sigma.formula.count_if(sigma.formula.eq(it.get_column_by_name('Status'), sigma.formula.const_text('Pending')))}`. +- Columns 14–24 beside the title are reserved for page actions — rows per + `blocks/buttons.md`. +- When the page's KPIs need a sentence to relate them, follow + the title block with an insight callout (`blocks/insight.md`), not a + longer subhead. + +## Progress rail + +Which step of an ordered flow the reader is on — intake → review → approve. +Navigation says where you can go; this says where you are. One structural +band under the title rule, so the page body starts at `y = 9` (7 at rows 1–4). + +```python +prog = home.add_container() +prog.set_frame(1, 7, 24, 2) +prog.set_column_density('high') +prog.set_padding(False) +prog.set_background_color(None) +prog.set_border(None) +s1 = prog.add_text('

➊ Commentary

') +s1.set_frame(1, 1, 7, 2) +s1.set_vertical_align('middle') +d1 = prog.add_divider('vertical') +d1.set_frame(8, 1, 1, 2) +d1.set_color(HAIRLINE) +# ➋ at (9,1,7,2), divider at (16,1,1,2), ➌ at (17,1,8,2) — the same calls +``` + +- Three or four steps; past that it is navigation, not a rail. Each page + rebuilds it, and the **only** thing that changes is which step carries + `PRIMARY` — the rest stay `MUTED`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md new file mode 100644 index 0000000..1277bc4 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/blocks/wizard.md @@ -0,0 +1,163 @@ +# Wizard — a modal stepping through tabs + +A stepped flow (name it, set parameters, review) in a modal: a hidden-bar +tabbed container holds the steps, marker buttons walk them. + +```python +wiz = sigma.workbook.add_modal('New scenario') +wiz.set_width('large') +wiz.set_header(title='', show_close_icon=True) # the body carries the title +wiz.set_footer_buttons(show_primary=False, show_secondary=False) # per-tab nav instead +wt = wiz.add_text('

****

') +wt.set_frame(1, 1, 12, 2) +ws = wiz.add_text('

Three steps, then it shows up in the library.

') +ws.set_frame(1, 3, 12, 1) + +wtc = wiz.add_tabbed_container() +wtc.set_frame(1, 5, 12, 12) +wtc.set_column_density('medium') # keep the modal's 12-column rhythm +steps = wtc.list_tabs() # a new tabbed container has 3 tabs — account for all three +step_basics = steps[0] +step_basics.set_name('Basics') +step_params = steps[1] +step_params.set_name('Parameters') +step_review = steps[2] +step_review.set_name('Review') +wtc.set_tab_bar_visible(False) +wtc.set_background_color(None) +wtc.set_padding(False) +wtc.set_gap(False) + +s1 = wiz.add_button('① Basics') # step markers: centered strip with +s1.set_frame(3, 4, 2, 1) # hairline separators +s1.set_appearance('text') +s1.set_align('stretch') +s1.set_font_color(BODY) +sd1 = wiz.add_divider('vertical') +sd1.set_frame(5, 4, 1, 1) +sd1.set_color(HAIRLINE) +s2 = wiz.add_button('② Parameters') +s2.set_frame(6, 4, 2, 1) +s2.set_appearance('text') +s2.set_align('stretch') +s2.set_font_color(BODY) +sd2 = wiz.add_divider('vertical') +sd2.set_frame(8, 4, 1, 1) +sd2.set_color(HAIRLINE) +s3 = wiz.add_button('③ Review') +s3.set_frame(9, 4, 2, 1) +s3.set_appearance('text') +s3.set_align('stretch') +s3.set_font_color(BODY) +s1.on_click('step 1', [sigma.action.selectTabEffect.select_tab(step_basics)]) +s2.on_click('step 2', [sigma.action.selectTabEffect.select_tab(step_params)]) +s3.on_click('step 3', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h1 = step_basics.add_text('**Name the scenario**') # step content sits in a +h1.set_frame(3, 1, 8, 1) # centered 8-of-12 column +nm = sigma.control.add_text_control(step_basics, placeholder='') +nm.set_name('Scenario name') # no target: variable-only, filters nothing +nm.set_frame(3, 2, 8, 2) +go1 = step_basics.add_button('Continue →') +go1.set_frame(8, 9, 3, 2) +go1.set_appearance('solid') +go1.set_shape('round') +go1.set_fill_color(PRIMARY) +go1.set_font_color('#ffffff') +go1.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) + +h2 = step_params.add_text('**Set the parameters**') +h2.set_frame(3, 1, 8, 1) +case = sigma.control.add_segmented_control(step_params, options=['Conservative', 'Base', 'Stretch'], value='Base') +case.set_name('Growth case') # options are manual — no target to derive from +case.set_frame(3, 2, 8, 2) +back2 = step_params.add_button('← Back') # neutral fill, left +back2.set_frame(3, 9, 3, 2) +back2.set_appearance('solid') +back2.set_shape('round') +back2.set_fill_color(FILL) +back2.set_font_color(BODY) +back2.on_click('to basics', [sigma.action.selectTabEffect.select_tab(step_basics)]) +go2 = step_params.add_button('Continue →') +go2.set_frame(8, 9, 3, 2) +go2.set_appearance('solid') +go2.set_shape('round') +go2.set_fill_color(PRIMARY) +go2.set_font_color('#ffffff') +go2.on_click('to review', [sigma.action.selectTabEffect.select_tab(step_review)]) + +h3 = step_review.add_text('**Review the setup**') +h3.set_frame(3, 1, 8, 1) +back3 = step_review.add_button('← Back') +back3.set_frame(3, 9, 3, 2) +back3.set_appearance('solid') +back3.set_shape('round') +back3.set_fill_color(FILL) +back3.set_font_color(BODY) +back3.on_click('to parameters', [sigma.action.selectTabEffect.select_tab(step_params)]) +done = step_review.add_button('Done') +done.set_frame(8, 9, 3, 2) +done.set_appearance('solid') +done.set_shape('round') +done.set_fill_color(PRIMARY) +done.set_font_color('#ffffff') +done.on_click('close', [sigma.action.overlayEffects.close_overlay()]) + +opener = page.add_button('+ New scenario') +opener.set_frame(19, 2, 6, 2) # title rows 1–4; under a single-page header band the title is rows 3–6: (19, 4, 6, 2) +opener.set_align('right') +opener.set_appearance('solid') +opener.set_shape('round') +opener.set_fill_color(PRIMARY) +opener.set_font_color('#ffffff') +opener.on_click('new scenario', [sigma.action.selectTabEffect.select_tab(step_basics), sigma.action.overlayEffects.open_overlay(wiz)]) +``` + +- The opener selects step 1 before opening, so the wizard never resumes mid-flow. + +## Form modal — one step, one record + +Create a record from a `+ New …` button without leaving the page: a modal +holding a form over the input table (`it` from `blocks/input-table.md`, +built first — on the hidden data page, `data_page.add_input_table(...)`, +when no band shows it, with two seed rows), the form's own submit doing +the insert. + +```python +m = sigma.workbook.add_modal('New goal') +m.set_width('small') +m.set_header(title='Set a new milestone', show_close_icon=True) +m.set_footer_buttons(show_primary=False, show_secondary=False) # the form's submit is the footer +form = m.add_form(it) +form.set_frame(1, 1, 12, 16) # 3 rows per field + 4 +form.set_title('') +form.set_description('') +form.set_submit_label('Create goal') # the outcome, never "Submit" +form.set_reset_visible(False) +form.set_submit_button_alignment('stretch') +form.set_spacing('medium') +form.set_background_color(None) +saved = form.on_submit('saved', [sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()]) +saved.set_success_toast(title='Goal created') +cancel = m.add_button('Cancel') # quiet secondary under the stretched submit +cancel.set_frame(1, 17, 12, 2) +cancel.set_appearance('text') +cancel.set_align('stretch') +cancel.set_font_color(MUTED) +cancel.set_bold(False) +cancel.on_click('cancel', [sigma.action.overlayEffects.close_overlay()]) +m.on_close('reset', [sigma.action.clearControlEffect.clear_control(m)]) +opener.on_click('new', [sigma.action.overlayEffects.open_overlay(m)]) # opener: blocks/buttons.md — a title-row action, or a card's soft button +``` + +Field defaults and dropdown presentation as `blocks/intake.md` shows. +- A formula **cannot reference a control's live value** — a review step + cannot echo the chosen inputs. Keep step 3 to a static summary and the + actions; live *data* state comes from the context-strip's + filtered-aggregate trick, not from controls. +- Replace the `done.on_click('close', …)` line to commit the controls as a + row — controls are action values: + `done.on_click('save', [sigma.action.inputTableEffects.insert_row(it, {i_name: nm, i_case: case}), sigma.action.refreshElementEffect.refresh_element(it), sigma.action.overlayEffects.close_overlay()])` + (`it` from `blocks/input-table.md`; `i_case` a single-select column added + for the case), then a toast on the sequence. Close resets the inputs: + `wiz.on_close('reset', [sigma.action.clearControlEffect.clear_control(wiz)])`. diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py new file mode 100755 index 0000000..5813228 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/check.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""Check a quickstart program, or a step-4 element dump, before it costs a call. + +Run it — do not read it. It catches the two classes of failure that otherwise +only surface remotely: constructs the Code Mode dialect rejects (a typecheck +error, nothing runs, the whole program is resubmitted) and geometry mistakes +(frames that overlap and silently relocate elements, children that overrun +their container, blank rows between bands). + + python3 scripts/check.py program.py # a program, before build_workbook + python3 scripts/check.py dump.txt # the step-4 host|id|name|frame dump + cat program.py | python3 scripts/check.py - + python3 scripts/check.py --fragment snippet.py # one block, not a whole program + +Findings are `ERROR` (this will not build, or will build wrong) and `WARN` +(look at it). Exit status is 1 when anything is an ERROR. A clean run is not a +promise the build succeeds — it is the local half of the check, and step 5's +screenshots are still the other half. +""" + +import ast +import builtins +import io +import json +import re +import sys +import tokenize +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") + +# The builtins a program may call (SKILL.md, "Program rules"). Anything else +# that resolves to a Python builtin is a construct the runtime does not expose. +ALLOWED_BUILTINS = { + "print", "len", "any", "enumerate", "zip", "range", "list", "reversed", + "sorted", "min", "max", "str", "isinstance", "datetime", "RuntimeError", +} + +# `add_*` calls that do not make a framed element. +NON_ELEMENT = { + "add_column", "add_editable_column", "add_formula_column", + "add_conditional_format", "add_source", "add_system_columns", + "add_pivot_row", "add_pivot_column", "add_pivot_value", "add_ref_band", +} +OVERLAY = {"add_popover", "add_modal", "add_drawer"} +PANEL = {"add_header", "add_sidebar"} +PAGE = {"add_page"} +# Hosts whose inner grid has exactly as many rows as their own frame spans. +CONTAINER = { + "add_container", "add_tabbed_container", "add_single_row_container", + "add_repeater", "add_form", +} + +# add_* -> the row in design-system.json's element_minimums +MINIMUM_FOR = { + "add_table": "Table", "add_input_table": "Input table", + "add_linked_input_table": "Input table", "add_pivot_table": "Pivot", + "add_kpi": "KPI", "add_text": "Text", "add_button": "Button", + "add_divider": "Divider", "add_container": "Container", + "add_tabbed_container": "Container", "add_single_row_container": "Container", + "add_repeater": "Repeater", "add_navigation": "Navigation", + "add_chat": "Chat", "add_form": "Form", "add_point_map": "Map", + "add_region_map": "Map", +} + +# Findings that need a whole program to mean anything. A block in blocks/ is a +# fragment: its `src`, `sec` and `y` come from the program that pastes it in, +# and it shows one element of a band rather than the finished band. +FRAGMENT_SUPPRESSED = { + "undefined-name", "missing-token-block", "no-frame", "blank-rows", + "trailing-empty-row", "action-below-fold", +} + +PLACEHOLDER_FORMAT = re.compile(r"\{\{[^}]*\|[^}]*\}\}") +SPAN_FONT = re.compile(r"]*font-(size|family)", re.I) +HTML_ENTITY = re.compile(r"&(lt|gt|amp|quot|nbsp|#39);") +DUMP_LINE = re.compile(r"^\s*([^|]+)\|([^|]*)\|([^|]*)\|\s*\(?\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)?\s*$") + + +class Finding: + def __init__(self, severity, line, code, message): + self.severity = severity + self.line = line + self.code = code + self.message = message + + def __str__(self): + where = f"line {self.line}" if self.line else "-" + return f"{self.severity:5s} {where:>9s} {self.code}: {self.message}" + + +class Element: + def __init__(self, var, kind, host, line): + self.var = var + self.kind = kind + self.host = host + self.line = line + self.frame = None + self.frame_line = None + self.unfoldable = False + + +def load_design_system(): + try: + return json.loads(DATA.read_text()) + except (OSError, ValueError): + return None + + +# ---------------------------------------------------------------- dialect --- + + +def check_dialect(tree, source, findings): + assigned = collect_assigned(tree) + nullable = collect_nullable(tree) + tokens_used = set() + tokens_assigned = set() + design = load_design_system() + token_names = set(design["tokens"]) if design else set() + + for node in ast.walk(tree): + line = getattr(node, "lineno", None) + + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + findings.append(Finding("ERROR", line, "no-def", + "`def` is not in the dialect — inline the calls instead")) + elif isinstance(node, ast.ClassDef): + findings.append(Finding("ERROR", line, "no-class", "`class` is not in the dialect")) + elif isinstance(node, ast.Lambda): + findings.append(Finding("ERROR", line, "no-lambda", "`lambda` is not in the dialect")) + elif isinstance(node, (ast.While,)): + findings.append(Finding("ERROR", line, "no-while", + "`while` is not in the dialect — a `for` over a list is")) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + findings.append(Finding("ERROR", line, "no-import", + "a program imports nothing — `sigma` is already bound")) + elif isinstance(node, (ast.Try, ast.With, ast.Assert)): + findings.append(Finding("WARN", line, "unsupported-statement", + f"`{type(node).__name__.lower()}` is not shown by any block; " + "check the SDK docs before relying on it")) + elif isinstance(node, ast.JoinedStr): + findings.append(Finding("ERROR", line, "no-fstring", + "an f-string collapses `{{placeholder}}` to `{placeholder}` and it " + "stops binding — build the string with `+`")) + + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mod, ast.Pow, ast.FloorDiv)): + symbol = {ast.Mod: "%", ast.Pow: "**", ast.FloorDiv: "//"}[type(node.op)] + findings.append(Finding("ERROR", line, "no-operator", + f"`{symbol}` is not in the dialect")) + + if isinstance(node, ast.Subscript): + if isinstance(node.slice, ast.Slice): + findings.append(Finding("ERROR", line, "no-slice", "slices are not in the dialect")) + elif isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + findings.append(Finding("ERROR", line, "handle-not-dict", + f"handles are objects — call it " + f"(`{ast.unparse(node.value)}({node.slice.value!r})`), " + f"never `[{node.slice.value!r}]`")) + + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)): + # A clause is a `for`. A filtering `if` is not one — SKILL.md's own + # data-model lookup is `[t for t in dm.list_tables() if t.name() == ...]`. + if len(node.generators) > 1: + findings.append(Finding("ERROR", line, "one-clause-comprehension", + "single-clause comprehensions only — one `for`, no nesting")) + + if isinstance(node, ast.Attribute) and node.attr == "format": + findings.append(Finding("ERROR", line, "no-format", + "`.format` collapses `{{placeholder}}` — build the string with `+`")) + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if PLACEHOLDER_FORMAT.search(node.value): + findings.append(Finding("ERROR", line, "placeholder-format-spec", + "a `{{placeholder}}` takes no format spec — it renders the literal " + "string; pre-format the value instead (blocks/formulas.md)")) + entity = HTML_ENTITY.search(node.value) + if entity: + findings.append(Finding("ERROR", line, "html-entity", + f"`{entity.group(0)}` renders as those characters on the page — " + "a span needs literal < and >; this is what escaping on the way " + "into the tool argument looks like")) + if SPAN_FONT.search(node.value): + findings.append(Finding("WARN", line, "span-font", + "a span sets color only — the type table fixes size, not `font-size`")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id in token_names: + tokens_used.add(node.id) + if ( + node.id not in assigned + and node.id not in ALLOWED_BUILTINS + and node.id != "sigma" + and not is_sdk_class(node.id) + ): + if hasattr(builtins, node.id): + findings.append(Finding("ERROR", line, "builtin-not-exposed", + f"`{node.id}` is not one of the exposed builtins" + + (" — aggregation is `sigma.formula.sum`" if node.id == "sum" else ""))) + else: + findings.append(Finding("ERROR", line, "undefined-name", + f"`{node.id}` is never assigned — a NameError at runtime")) + + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store) and node.id in token_names: + tokens_assigned.add(node.id) + + if isinstance(node, ast.Call): + check_call(node, line, findings) + check_nullable_argument(node, line, nullable, findings) + + if token_names: + missing = sorted(tokens_used - tokens_assigned) + if missing: + findings.append(Finding("ERROR", None, "missing-token-block", + "the opening token block is missing these: " + ", ".join(missing) + + " — every call for the workbook opens with all twelve " + "(scripts/theme.py prints them)")) + + check_adjacent_strings(source, findings) + + +def check_call(node, line, findings): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + + if isinstance(func, ast.Attribute): + chain = ast.unparse(func) + if chain.startswith("sigma.data.sql"): + findings.append(Finding("ERROR", line, "no-in-program-sql", + "a program runs no SQL — resolve values with the `query` tool in step 3")) + if name in {"help", "dir"}: + findings.append(Finding("ERROR", line, "no-probe", "probes are rejected")) + + if name in {"help", "dir"} and not isinstance(func, ast.Attribute): + findings.append(Finding("ERROR", line, "no-probe", + "probes (`help()`, `dir()`) are rejected — read the SDK docs instead")) + + if name == "add_column" and len(node.args) >= 2: + second = node.args[1] + if isinstance(second, ast.Call) and isinstance(second.func, ast.Attribute): + if second.func.attr in {"get_column_by_name", "column"}: + findings.append(Finding("ERROR", line, "add-column-needs-formula", + "`add_column` takes a Formula, not a column — wrap it " + "(`sigma.formula.text(col)`, `number`, `date`)")) + + if name == "set_surface_color" and node.args: + value = literal_str(node.args[0]) + if value and value.lower() in {"#ffffff", "#fff", "white"}: + findings.append(Finding("ERROR", line, "white-surface", + "a white surface color makes every input lose its border — use '#8c8c8c'")) + + if name == "set_comparison" and len(node.args) == 1 and is_none(node.args[0]): + findings.append(Finding("WARN", line, "kpi-without-context", + "a KPI with no comparison leaves a visible gap and tells the reader " + "nothing — give it a comparison column or a prior period")) + + if name == "duplicate": + findings.append(Finding("WARN", line, "no-duplicate", + "`duplicate()` drops the copy a row below the original and keeps the " + "doubled height — build the second element the long way")) + + if name and name.endswith("_color"): + for argument in node.args: + if ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Attribute) + and argument.func.attr == "highlight_color" + ): + findings.append(Finding("ERROR", line, "highlight-as-color", + "the highlight is read to pick a hue row, never passed as a " + "color — use SERIES, SERIES_MID, SERIES_GREY or the ramp")) + + +def is_sdk_class(name): + """`Container`, `ListControl`, `SingleRowContainer` — the classes an + `isinstance` narrow names are bound by the runtime, not by the program. + A token is ALL_CAPS, so it never matches here.""" + return name[:1].isupper() and any(character.islower() for character in name) + + +def literal_str(node): + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None + + +def is_none(node): + return isinstance(node, ast.Constant) and node.value is None + + +def collect_nullable(tree): + """Names that are assigned a bare `None` anywhere in the program.""" + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and is_none(node.value): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def check_nullable_argument(node, line, nullable, findings): + """`t = None` then reassigned in a loop types `t` as `None | Source`. + + The typecheck rejects it even when every path assigns something — seed the + variable from the first real value instead of from None. + """ + if not isinstance(node.func, ast.Attribute) or not node.func.attr.startswith("add_"): + return + for argument in node.args: + if isinstance(argument, ast.Name) and argument.id in nullable: + findings.append(Finding("ERROR", line, "nullable-argument", + f"`{argument.id}` is assigned `None` somewhere, so it types as " + f"`None | ...` and `{node.func.attr}` rejects it — seed it from the " + "first real value instead")) + + +def collect_assigned(tree): + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + return names + + +def check_adjacent_strings(source, findings): + """Two string literals side by side concatenate in Python but not here.""" + try: + stream = tokenize.generate_tokens(io.StringIO(source).readline) + previous = None + for token in stream: + if token.type == tokenize.STRING: + if previous is not None and previous.end[0] == token.start[0]: + findings.append(Finding("ERROR", token.start[0], "adjacent-strings", + "adjacent string literals do not concatenate — join them with `+`")) + previous = token + elif token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT): + previous = None + except (tokenize.TokenError, IndentationError): + pass + + +# ----------------------------------------------------------------- layout --- + + +class LayoutModel: + """Elements, their hosts, and their frames, folded from the program text.""" + + def __init__(self): + self.elements = {} + self.order = [] + self.ints = {} + + def add(self, element): + self.elements[element.var] = element + self.order.append(element.var) + + def fold(self, node): + """Best-effort integer value of an expression, or None.""" + if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool): + return node.value + if isinstance(node, ast.Name): + return self.ints.get(node.id) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + inner = self.fold(node.operand) + return None if inner is None else -inner + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub)): + left, right = self.fold(node.left), self.fold(node.right) + if left is None or right is None: + return None + return left + right if isinstance(node.op, ast.Add) else left - right + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + # prev.y() / prev.height() — the y-cursor idiom + owner = node.func.value + if isinstance(owner, ast.Name): + element = self.elements.get(owner.id) + if element and element.frame: + index = {"x": 0, "y": 1, "width": 2, "height": 3}.get(node.func.attr) + if index is not None: + return element.frame[index] + return None + + +def build_layout(tree, findings): + model = LayoutModel() + + for statement in walk_statements(tree): + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + target = statement.targets[0] + value = statement.value + if isinstance(target, ast.Name): + folded = model.fold(value) + if folded is not None: + model.ints[target.id] = folded + if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): + record_element(model, target.id, value, statement.lineno) + + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "set_frame": + owner = call.func.value + if isinstance(owner, ast.Name): + apply_frame(model, owner.id, call, findings) + + return model + + +def walk_statements(tree): + """Statements in source order, descending into `for` bodies.""" + for statement in tree.body: + yield statement + if isinstance(statement, ast.For): + for inner in statement.body: + yield inner + + +def record_element(model, var, call, line): + kind = call.func.attr + if not kind.startswith("add_") or kind in NON_ELEMENT: + return + owner = call.func.value + if isinstance(owner, ast.Name): + host = owner.id + else: + host = ast.unparse(owner) + if host.startswith("sigma."): + # `sigma.control.add_list_control(bar, ...)` and friends take their host + # as the first argument; `sigma.workbook.add_popover(trigger, ...)` makes + # an overlay page rather than an element on the trigger's host. + if kind in OVERLAY: + host = "" + elif call.args and isinstance(call.args[0], ast.Name): + host = call.args[0].id + else: + host = "" + model.add(Element(var, kind, host, line)) + + +def apply_frame(model, var, call, findings): + element = model.elements.get(var) + if element is None: + return + if len(call.args) != 4: + findings.append(Finding("ERROR", call.lineno, "frame-arity", + "set_frame takes four integers (x, y, width, height)")) + return + values = [model.fold(argument) for argument in call.args] + element.frame_line = call.lineno + if any(value is None for value in values): + element.unfoldable = True + return + element.frame = tuple(values) + + +def columns_for(model, host): + element = model.elements.get(host) + if host == "": + return 12 + if element and element.kind == "add_repeater": + return 12 + return 24 + + +def check_layout(model, findings): + design = load_design_system() + minimums = design["element_minimums"] if design else {} + grid = design["grid"] if design else {} + fold_row = grid.get("fold_row", 28) + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for var in model.order: + element = model.elements[var] + if element.kind in OVERLAY | PANEL | PAGE: + continue + if element.frame is None and not element.unfoldable: + findings.append(Finding("WARN", element.line, "no-frame", + f"`{element.var}` never gets a set_frame — there is no auto height")) + continue + if element.frame is None: + continue + + x, y, width, height = element.frame + if x < 1 or y < 1: + findings.append(Finding("ERROR", element.frame_line, "frame-origin", + f"`{element.var}` starts at ({x}, {y}) — the grid origin is (1, 1)")) + limit = columns_for(model, element.host) + if x + width - 1 > limit: + findings.append(Finding("ERROR", element.frame_line, "off-grid", + f"`{element.var}` ends at column {x + width - 1}, past the " + f"{limit}-column grid of its host")) + + row = MINIMUM_FOR.get(element.kind) + if row and row in minimums: + min_width, min_height = minimums[row] + if width < min_width or height < min_height: + findings.append(Finding("ERROR", element.frame_line, "below-minimum", + f"`{element.var}` is {width}x{height}; a {row} clips below " + f"{min_width}x{min_height}")) + + if element.kind == "add_button": + absolute = absolute_y(model, element) + if absolute is not None and absolute > fold_row: + findings.append(Finding("WARN", element.frame_line, "action-below-fold", + f"`{element.var}` sits at page row {absolute} — a reader never " + f"scrolls to act; move its band above row {fold_row}")) + + for host, children in by_host.items(): + framed = [child for child in children if child.frame] + check_overlaps(host, framed, findings) + check_host_fit(model, host, framed, findings) + + +def check_overlaps(host, framed, findings): + for index, first in enumerate(framed): + for second in framed[index + 1 :]: + if rectangles_overlap(first.frame, second.frame): + findings.append(Finding("ERROR", second.frame_line, "overlapping-frames", + f"`{second.var}` overlaps `{first.var}` inside `{host}` — an " + "overlapping frame silently relocates the elements already placed")) + + +def rectangles_overlap(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah + + +def check_host_fit(model, host, framed, findings): + if not framed: + return + grid = (load_design_system() or {}).get("grid", {}) + element = model.elements.get(host) + bottom = max(child.frame[1] + child.frame[3] - 1 for child in framed) + + if element is not None and element.kind in CONTAINER and element.frame: + rows = element.frame[3] + if element.kind == "add_repeater": + rows = grid.get("repeater_card_rows", 5) + if bottom > rows: + findings.append(Finding("ERROR", element.frame_line, "child-overruns-host", + f"a child of `{host}` ends on row {bottom} but its inner grid has " + f"{rows} — it grows the host and pushes the bands below it down")) + elif bottom < rows and not sized_to_something_else(element, rows, grid): + findings.append(Finding("WARN", element.frame_line, "trailing-empty-row", + f"`{host}` spans {rows} rows but its last child ends on row " + f"{bottom} — a reserved row renders as a visible gap")) + + if element is None or element.kind in PAGE: + for low, high in uncovered_runs(framed): + findings.append(Finding("WARN", framed[0].frame_line, "blank-rows", + f"rows {low}-{high} on `{host}` hold nothing — the y-cursor leaves " + "no gap between bands")) + + +def sized_to_something_else(element, rows, grid): + """Hosts the "size a card to its content" rule exempts. + + A full-height column runs the page rather than its contents, a tabbed main + is sized by the page, and an input table or form is sized by its rows — all + of them legitimately end well short of their last row. A repeater card is + not exempt: its template still has to end on row 5. + """ + if element.kind in {"add_input_table", "add_linked_input_table", "add_form", + "add_tabbed_container"}: + return True + if element.kind == "add_repeater": + return False + return rows > grid.get("band_height_max", 18) + + +def uncovered_runs(framed): + """Row spans between the first and last occupied row that nothing sits on. + + Coverage, not pairwise gaps: a chip beside a title, a mosaic's shallow + cards, and both halves of a split band all overlap in rows by design, and + walking siblings in y order reads every one of them as a hole. + """ + covered = set() + for child in framed: + _, y, _, height = child.frame + covered.update(range(y, y + height)) + if not covered: + return [] + runs, start = [], None + for row in range(min(covered), max(covered) + 1): + if row not in covered and start is None: + start = row + elif row in covered and start is not None: + runs.append((start, row - 1)) + start = None + return runs + + +def absolute_y(model, element): + y = element.frame[1] + host = element.host + seen = set() + while host in model.elements and host not in seen: + seen.add(host) + parent = model.elements[host] + if parent.kind in PAGE | PANEL | OVERLAY or parent.frame is None: + break + y += parent.frame[1] - 1 + host = parent.host + return y + + +# ------------------------------------------------------------------- dump --- + + +def check_dump(text, findings): + """The step-4 dump: `host|el.id()|el.name()|el.frame()`, one line each.""" + model = LayoutModel() + count = 0 + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip() or "|" not in line: + continue + match = DUMP_LINE.match(line) + if not match: + continue + host, identifier, name, x, y, width, height = match.groups() + count += 1 + var = (name.strip() or identifier.strip() or f"row {number}") + if var in model.elements: + var = f"{var} ({number})" + element = Element(var, "add_container", host.strip(), number) + element.frame = (int(x), int(y), int(width), int(height)) + element.frame_line = number + model.add(element) + + if not count: + findings.append(Finding("ERROR", None, "no-dump-rows", + "no `host|id|name|(x, y, w, h)` rows found")) + return + + by_host = {} + for var in model.order: + element = model.elements[var] + by_host.setdefault(element.host, []).append(element) + + for host, children in by_host.items(): + check_overlaps(host, children, findings) + for child in children: + x, _, width, _ = child.frame + if x + width - 1 > 24: + findings.append(Finding("ERROR", child.frame_line, "off-grid", + f"`{child.var}` ends at column {x + width - 1}, past 24")) + ordered = sorted(children, key=lambda child: child.frame[1]) + for previous, following in zip(ordered, ordered[1:]): + bottom = previous.frame[1] + previous.frame[3] - 1 + if following.frame[1] > bottom + 1: + findings.append(Finding("WARN", following.frame_line, "blank-rows", + f"rows {bottom + 1}-{following.frame[1] - 1} are blank between " + f"`{previous.var}` and `{following.var}` under `{host}`")) + + print(f"# read {count} elements across {len(by_host)} hosts", file=sys.stderr) + + +# ------------------------------------------------------------------- main --- + + +def looks_like_dump(text): + rows = [line for line in text.splitlines() if DUMP_LINE.match(line)] + return len(rows) >= 2 and len(rows) >= len([l for l in text.splitlines() if l.strip()]) // 2 + + +def check_program(source, findings, fragment=False): + try: + tree = ast.parse(source) + except SyntaxError as error: + findings.append(Finding("ERROR", error.lineno, "syntax", + f"{error.msg} — `{(error.text or '').strip()}`")) + return + check_dialect(tree, source, findings) + model = build_layout(tree, findings) + check_layout(model, findings) + if fragment: + findings[:] = [f for f in findings if f.code not in FRAGMENT_SUPPRESSED] + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] in {"-h", "--help"}: + print(__doc__) + return 0 + + fragment = "--fragment" in argv + argv = [a for a in argv if a != "--fragment"] + if not argv: + print(__doc__) + return 0 + + target = argv[0] + if target == "-": + source = sys.stdin.read() + label = "" + else: + path = Path(target) + if not path.exists(): + print(f"error: no such file: {target}", file=sys.stderr) + return 2 + source = path.read_text() + label = path.name + + findings = [] + if looks_like_dump(source): + check_dump(source, findings) + else: + check_program(source, findings, fragment=fragment) + + errors = [f for f in findings if f.severity == "ERROR"] + warnings = [f for f in findings if f.severity == "WARN"] + for finding in sorted(findings, key=lambda f: (f.severity != "ERROR", f.line or 0)): + print(finding) + + if not findings: + print(f"{label}: clean — no dialect or geometry findings") + else: + print(f"\n{label}: {len(errors)} error(s), {len(warnings)} warning(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json new file mode 100644 index 0000000..758a115 --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/design-system.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "Source of truth for the fixed design system. Authored here; SKILL.md's two", + "theme tables are GENERATED from this file by scripts/gen-design-system.py,", + "and SKILL.md's 'Grid and size by type' minimums are VERIFIED against it by", + "scripts/lint-corpus.py. Edit this file, then rebuild — never edit the", + "tables in SKILL.md by hand." + ], + "tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY", + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "base_tokens": [ + "CANVAS", + "INK", + "BODY", + "MUTED", + "HAIRLINE", + "FILL", + "SERIES_GREY" + ], + "hue_tokens": [ + "PRIMARY", + "TINT", + "EDGE", + "SERIES", + "SERIES_MID" + ], + "bases": { + "Neutral": ["#fafafa", "#0a0a0a", "#525252", "#737373", "#e5e5e5", "#f5f5f5", "#d4d4d4"], + "Stone": ["#fafaf9", "#0c0a09", "#57534d", "#79716b", "#e7e5e4", "#f5f5f4", "#d6d3d1"], + "Zinc": ["#fafafa", "#09090b", "#52525c", "#71717b", "#e4e4e7", "#f4f4f5", "#d4d4d8"], + "Slate": ["#f8fafc", "#020618", "#45556c", "#62748e", "#e2e8f0", "#f1f5f9", "#cad5e2"] + }, + "hues": { + "Neutral": ["#171717", "#e5e5e5", "#d4d4d4", "#525252", "#a1a1a1"], + "Blue": ["#1447e6", "#dbeafe", "#bedbff", "#155dfc", "#51a2ff"], + "Indigo": ["#432dd7", "#e0e7ff", "#c6d2ff", "#4f39f6", "#7c86ff"], + "Violet": ["#7008e7", "#ede9fe", "#ddd6ff", "#7f22fe", "#a684ff"], + "Emerald": ["#007a55", "#d0fae5", "#a4f4cf", "#009966", "#00d492"], + "Teal": ["#00786f", "#cbfbf1", "#96f7e4", "#009689", "#00d5be"], + "Green": ["#008236", "#dcfce7", "#b9f8cf", "#00a63e", "#05df72"], + "Rose": ["#c70036", "#ffe4e6", "#ffccd3", "#ec003f", "#ff637e"], + "Orange": ["#ca3500", "#ffedd4", "#ffd6a7", "#f54900", "#ff8904"], + "Amber": ["#bb4d00", "#fef3c6", "#fee685", "#e17100", "#ffb900"] + }, + "defaults": { + "base": "Neutral", + "hue": "Blue" + }, + "fixed_literals": { + "white": "#ffffff", + "good_text": "#008236", + "good_fill": "#dcfce7", + "bad_text": "#c10007", + "bad_fill": "#ffe2e2", + "warn_text": "#bb4d00", + "warn_fill": "#fef3c6" + }, + "theme_calls": [ + ["set_canvas_color", "CANVAS"], + ["set_surface_color", "#8c8c8c"], + ["set_text_color", "INK"], + ["set_title_color", "INK"], + ["set_highlight_color", "PRIMARY"], + ["set_text_font", "Inter"], + ["set_data_font", "Inter"], + ["set_page_width", "large"] + ], + "element_minimums": { + "_comment": "min [width, height] in page-grid units; SKILL.md's 'Grid and size by type' table is checked against this by scripts/lint-corpus.py", + "Chart": [4, 6], + "Map": [6, 8], + "KPI": [2, 3], + "Table": [4, 3], + "Pivot": [4, 3], + "Text": [1, 1], + "Button": [2, 1], + "Control": [2, 2], + "Navigation": [2, 2], + "Divider": [1, 1], + "Container": [1, 2], + "Repeater": [12, 9], + "Input table": [6, 4], + "Form": [6, 8], + "Chat": [3, 4] + }, + "grid": { + "page_columns": 24, + "overlay_columns": 12, + "repeater_card_columns": 12, + "repeater_card_rows": 5, + "row_pitch_px": 24, + "fold_row": 28, + "band_height_min": 10, + "band_height_max": 18, + "strip_height_min": 2, + "strip_height_max": 6 + } +} diff --git a/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py new file mode 100755 index 0000000..a64402b --- /dev/null +++ b/plugins/claude/sigma-build-gcp-us-central-1-production/skills/sigma-workbook-quickstart/scripts/theme.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Resolve the twelve theme tokens and print the block every program opens with. + +Run it — do not read it. One call replaces the two lookup tables in SKILL.md's +`### Theme`, and picks the hue row by hue angle in OKLCH rather than by name, +which is what the tables cannot do for a brand hex. + + python3 scripts/theme.py # the default, Neutral / Blue + python3 scripts/theme.py --highlight '#2f6bdd' # what theme.highlight_color() returned + python3 scripts/theme.py --color emerald # a color word in the ask + python3 scripts/theme.py --base slate # a neutral word in the ask + python3 scripts/theme.py --brand '#ff5733' # a brand hex: PRIMARY is it exactly + +A color word outranks a highlight read; a brand hex outranks both. Anything +unrecognised falls back to the default rather than failing, so the program can +always be written. +""" + +import argparse +import json +import math +import re +import sys +from pathlib import Path + +DATA = Path(__file__).with_name("design-system.json") +HEX = re.compile(r"^#?([0-9a-fA-F]{6})$") +# Below this OKLCH chroma a colour has no hue worth matching — it is a grey. +ACHROMATIC = 0.03 + + +def load(): + return json.loads(DATA.read_text()) + + +def parse_hex(value): + """'#1447E6' or '1447e6' -> (20, 71, 230). None when it is not a hex color.""" + if not value: + return None + match = HEX.match(value.strip()) + if not match: + return None + digits = match.group(1) + return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4)) + + +def to_oklch(rgb): + """sRGB 0-255 -> OKLCH. Plain arithmetic, so the script stays stdlib-only. + + OKLCH is the space Tailwind publishes its ramps in, which is where every + row in design-system.json comes from. + """ + + def linear(channel): + c = channel / 255 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + r, g, b = (linear(c) for c in rgb) + long = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b + medium = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b + short = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b + l_, m_, s_ = long ** (1 / 3), medium ** (1 / 3), short ** (1 / 3) + lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ + a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ + b_axis = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ + chroma = (a * a + b_axis * b_axis) ** 0.5 + hue = math.degrees(math.atan2(b_axis, a)) % 360 + return lightness, chroma, hue + + +def hue_gap(first, second): + """Shortest angular distance between two hues, in degrees.""" + gap = abs(first - second) % 360 + return min(gap, 360 - gap) + + +def nearest_hue(data, rgb): + """The hue row in the same family as rgb — matched on hue angle alone. + + Lightness and chroma come from the row's own shades, so letting them into + the distance picks the wrong family: a dull purple scores closer to vivid + Indigo than to vivid Violet on full dE, and then the charts run ~27 degrees + off the PRIMARY they sit beside. Hue only, and a colour with too little + chroma to have a hue is Neutral. + """ + _, chroma, hue = to_oklch(rgb) + if chroma < ACHROMATIC: + return "Neutral" + scored = [ + (hue_gap(hue, to_oklch(parse_hex(values[0]))[2]), name) + for name, values in data["hues"].items() + if name != "Neutral" # its hue angle is noise; the chroma guard reaches it + ] + return min(scored)[1] + + +def match_name(table, word): + """Case-insensitive exact row name, else None.""" + if not word: + return None + for name in table: + if name.lower() == word.strip().lower(): + return name + return None + + +def resolve(data, highlight=None, color=None, base=None, brand=None): + """Returns (base_row, hue_row, primary_override, notes).""" + notes = [] + base_row = match_name(data["bases"], base) + if base and not base_row: + notes.append(f"base {base!r} is not a row; kept the default") + if not base_row: + base_row = data["defaults"]["base"] + + hue_row = None + primary = None + + brand_rgb = parse_hex(brand) + if brand and not brand_rgb: + notes.append(f"brand {brand!r} is not a hex color; ignored") + if brand_rgb: + hue_row = nearest_hue(data, brand_rgb) + primary = "#" + "".join(f"{c:02x}" for c in brand_rgb) + notes.append(f"PRIMARY is the brand hex; the rest of the hue row is {hue_row}") + + if hue_row is None: + named = match_name(data["hues"], color) + if color and not named: + notes.append(f"color word {color!r} is not a hue row; fell through") + if named: + hue_row = named + notes.append(f"the ask named {named}, which outranks any highlight read") + + if hue_row is None: + rgb = parse_hex(highlight) + if highlight and not rgb: + notes.append(f"highlight {highlight!r} is not a hex color; used the default") + if rgb: + hue_row = nearest_hue(data, rgb) + notes.append(f"hue row nearest the workbook highlight {highlight} is {hue_row}") + + if hue_row is None: + hue_row = data["defaults"]["hue"] + notes.append(f"no color named and no highlight read; used {hue_row}") + + return base_row, hue_row, primary, notes + + +def render(data, base_row, hue_row, primary): + values = dict(zip(data["base_tokens"], data["bases"][base_row])) + values.update(zip(data["hue_tokens"], data["hues"][hue_row])) + if primary: + values["PRIMARY"] = primary + + lines = [ + "# tokens — a base row, then a hue row (### Theme); every program opens with them" + ] + for token in data["base_tokens"]: + lines.append(f"{token} = '{values[token]}'") + for token in data["hue_tokens"]: + lines.append(f"{token} = '{values[token]}'") + + lines.append("") + lines.append("theme = sigma.workbook.theme()") + for call, argument in data["theme_calls"]: + token = argument if argument in values else None + if token: + lines.append(f"theme.{call}({token})") + else: + lines.append(f"theme.{call}('{argument}')") + return "\n".join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Print the twelve-token opening block for a quickstart program." + ) + parser.add_argument("--highlight", help="hex returned by theme.highlight_color()") + parser.add_argument("--color", help="a color word in the ask (emerald, blue, ...)") + parser.add_argument("--base", help="a neutral word in the ask (slate, stone, zinc)") + parser.add_argument("--brand", help="a brand hex; PRIMARY becomes exactly this") + parser.add_argument( + "--list", action="store_true", help="print the available base and hue rows" + ) + args = parser.parse_args(argv) + + try: + data = load() + except (OSError, ValueError) as error: + print(f"error: cannot read {DATA.name}: {error}", file=sys.stderr) + print("fall back to SKILL.md's `### Theme` tables.", file=sys.stderr) + return 1 + + if args.list: + print("bases:", ", ".join(data["bases"])) + print("hues: ", ", ".join(data["hues"])) + return 0 + + base_row, hue_row, primary, notes = resolve( + data, args.highlight, args.color, args.base, args.brand + ) + print(render(data, base_row, hue_row, primary)) + print() + print(f"# chosen: base {base_row}, hue {hue_row}") + for note in notes: + print(f"# {note}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())