Cyber Security  ·  Level 5
Perform Website Design And Development
Chapter 3: Develop functional website
📚 6 Topics
⚑ What you will be able to do

By the end of this chapter, you will be able to:

  • Ensure the website meets all functional requirements like working navigation, form submissions, and media loading during testing.
  • Test and confirm that all web pages load quickly, aiming for under three seconds on normal internet speeds.
  • Create a fully responsive website design that works smoothly on at least three different devices or screen sizes.
  • Pass mobile-friendly tests without any layout problems to provide a great user experience.

Mastering these skills means you can build websites that work well and look great everywhere, helping you succeed in the fast-growing web development field!

Developing a functional website is a foundational skill for cyber security professionals who need to understand how web applications operate, how vulnerabilities can arise, and how to build secure interfaces. Mastery of HTML and JavaScript coding enables professionals to create, analyze, and safeguard web pages against common threats such as cross-site scripting and injection attacks. This chapter delves into the essential HTML tags and JavaScript components that underpin interactive websites, providing cyber security students with practical knowledge to design secure and effective web pages within Kenyan and global contexts.

3.1 HTML CODING

HTML (HyperText Markup Language) is the backbone of all web pages, defining the structure and content displayed by browsers. For cyber security professionals, comprehending HTML tags is crucial to identify potential injection points and enforce secure coding practices. In Kenya’s expanding digital landscape, such as in financial institutions like KCB or government portals, secure HTML coding is vital to protect sensitive data and user interactions.

3.1.1 Formatting tags

Formatting tags in HTML control how text and other elements appear on a webpage, affecting readability and user experience. These tags are fundamental to structuring content logically and visually, which also impacts how browsers interpret and render pages.

Common Text Formatting Tags

  • and : Both tags make text bold, but indicates importance semantically, which search engines and screen readers recognize differently.
  • and : These tags italicize text, with adding emphasis for accessibility tools.
  • : Underlines text, though it is less common due to potential confusion with hyperlinks.
  • : Displays text in a smaller font size, often used for disclaimers or footnotes.
  • : Highlights text with a background color, useful for emphasizing search terms or warnings.

Structural Formatting Tags

  • : Defines paragraphs, providing spacing and separation of content.

  • to

    : Heading tags that create a hierarchy of importance;

    is the main title, while

    is the least important.

  • : Inserts a line break without starting a new paragraph, useful for addresses or poems.

  • : Inserts a horizontal rule, visually separating sections of content.

Semantic Importance and Accessibility

Proper use of formatting tags enhances the semantic meaning of webpages, aiding search engine optimization (SEO) and accessibility for users relying on screen readers. For example, using instead of alerts assistive technologies to important text, improving usability for people with disabilities.

Security Considerations

Incorrect use of formatting tags can lead to security issues such as improper display of user-generated content, which may be exploited through HTML injection. For instance, failing to encode user inputs that include formatting tags can allow attackers to alter page content or insert malicious scripts.

3.1.2 Hyperlinks tag

Hyperlinks enable navigation between web pages and resources, making the internet interconnected. In cyber security, understanding hyperlinks is essential to prevent phishing attacks and ensure safe redirection.

The Anchor Tag

The tag defines a hyperlink. Its primary attribute, href, specifies the destination URL. Example: <a href="https://www.nema.go.ke">NEMA</a> links to the National Environment Management Authority website.

Target Attribute and Its Uses

The target attribute controls where the linked document opens. Common values include:

  • _self: Opens in the same tab (default).
  • _blank: Opens in a new tab or window, useful for external links to avoid losing the original page.
  • _parent and _top: Used within frames or nested browsing contexts.

Link Security Attributes

  • rel="noopener noreferrer": Prevents the new page from accessing the window object of the linking page, mitigating phishing and tab-nabbing attacks.
  • nofollow: Instructs search engines not to follow the link, useful for untrusted or paid links.

Email and Telephone Links

Hyperlinks can initiate email or phone calls using mailto: and tel: schemes, respectively. For example, <a href="mailto:info@county.go.ke">Email Us</a> enables users to send emails directly.

3.1.3 Tables tags

Tables organize data into rows and columns, essential for displaying structured information like user logs or audit trails in cyber security dashboards.

Core Table Tags

  • : Defines the table container.
  • : Table row, groups cells horizontally.
  • : Header cell, usually bold and centered.
  • : Standard data cell.

    Table Structure and Accessibility

    Proper table markup with

    for titles and scope attributes for headers improves navigation for screen readers, critical in government portals such as county offices for compliance with accessibility laws.

    Styling and Responsive Tables

    Tables can be styled with CSS for better visual appeal and responsiveness on mobile devices. Responsive tables are crucial for users accessing cyber security dashboards on smartphones or tablets.

    Security Implications

    Displaying user-generated data in tables requires sanitization to prevent injection attacks. For example, unfiltered input in a table cell could lead to cross-site scripting (XSS) vulnerabilities in a SACCO’s member portal.

    3.1.4 Frames tags

    Frames allow multiple HTML documents to be displayed simultaneously in one browser window, historically used for navigation menus or content separation.

    Basic Frame Elements

    • : Defines a set of frames.
    • : Defines each individual frame.
    • </strong>: Provides alternative content for browsers that do not support frames.</li> </ul> <h4>Drawbacks and Modern Alternatives</h4> <p>Frames complicate navigation, bookmarking, and SEO, and are deprecated in HTML5. Modern web design prefers <strong><iframe></strong> or CSS-based layouts.</p> <h4>Use Cases for <iframe></h4> <p>The <strong><iframe></strong> tag embeds external content like videos or maps. Cyber security professionals must ensure embedded content does not introduce vulnerabilities such as clickjacking.</p> <h4>Security Concerns with Frames</h4> <p>Frames can be exploited for phishing by hiding malicious content or misleading users about the source of information. Implementing <strong>X-Frame-Options</strong> HTTP headers prevents unauthorized framing.</p> <h3>3.1.5 Forms tags</h3> <p>Forms collect user input, enabling interactive features such as login pages, feedback, and data submission, which are critical in cyber security systems for authentication and reporting.</p> <h4>Form Structure Tags</h4> <ul> <li><strong><form></strong>: Container for form elements, with attributes like <strong>action</strong> (submission URL) and <strong>method</strong> (GET or POST).</li> <li><strong><input></strong>: Various types for text, password, checkbox, radio, file, etc.</li> <li><strong><textarea></strong>: Multi-line text input.</li> <li><strong><select></strong> and <strong><option></strong>: Dropdown menus.</li> </ul> <h4>Input Types and Validation</h4> <p>HTML5 introduced input types like <strong>email</strong>, <strong>url</strong>, and <strong>number</strong> to improve data validation on the client side, reducing server-side processing and enhancing security.</p> <h4>Form Security Best Practices</h4> <ul> <li>Use <strong>POST</strong> method for sensitive data to avoid exposing it in URLs.</li> <li>Implement client-side and server-side validation to prevent injection attacks.</li> <li>Use <strong>autocomplete="off"</strong> for sensitive fields like passwords to prevent credential leaks.</li> </ul> <h4>Accessibility Considerations</h4> <p>Labels linked to inputs via the <strong>for</strong> attribute improve usability for assistive technologies, essential for compliance in public sector websites such as county government portals.</p> <h3>3.1.6 List tags</h3> <p>Lists organize information clearly, improving readability and navigation on web pages, often used for menus, steps, or itemized data.</p> <h4>Types of Lists</h4> <ul> <li><strong><ul></strong>: Unordered list, with bullet points.</li> <li><strong><ol></strong>: Ordered list, numbered.</li> <li><strong><dl></strong>: Definition list, with terms and descriptions.</li> </ul> <h4>Nested and Custom Lists</h4> <p>Lists can be nested within each other to create multi-level menus or hierarchical information. CSS can customize list markers for branding purposes.</p> <h4>Semantic Importance</h4> <p>Using appropriate list types conveys meaning to search engines and screen readers, enhancing SEO and accessibility.</p> <h4>Practical Use in Cyber Security Webpages</h4> <p>Lists are used to display security policies, user instructions, or audit logs in a structured format on portals like NHIF or insurance companies.</p> <h3>3.1.13 JS Events</h3> <p>JavaScript events enable interactive web pages by responding to user actions or browser occurrences, essential for dynamic cyber security applications like real-time alerts.</p> <h4>Event Types</h4> <ul> <li><strong>Mouse events</strong>: click, dblclick, mouseover.</li> <li><strong>Keyboard events</strong>: keydown, keyup, keypress.</li> <li><strong>Form events</strong>: submit, change, focus, blur.</li> <li><strong>Window events</strong>: load, resize, scroll.</li> </ul> <h4>Event Handling Methods</h4> <p>Events can be handled inline in HTML, via DOM properties, or using <strong>addEventListener()</strong> for flexible and multiple event listeners.</p> <h4>Event Propagation</h4> <p>Understanding event bubbling and capturing phases is critical in managing event flow and preventing unintended behaviors in complex web applications.</p> <h4>Security Implications</h4> <p>Proper event handling prevents malicious scripts from hijacking user interactions. For example, validating inputs on key events can mitigate injection risks.</p> <h3>3.1.14 JS Strings</h3> <p>Strings in JavaScript represent text data and are manipulated for tasks such as input validation, formatting, and data processing in cyber security tools.</p> <h4>String Creation and Properties</h4> <p>Strings can be created using single, double, or backticks (template literals), affecting how variables and expressions are embedded.</p> <h4>Common String Methods</h4> <p>Methods like <strong>length</strong>, <strong>indexOf()</strong>, <strong>slice()</strong>, <strong>replace()</strong>, and <strong>toUpperCase()</strong> support manipulation and searching within strings, vital for sanitizing user input.</p> <h4>Template Literals and Interpolation</h4> <p>Template literals allow embedding expressions, improving code readability when constructing messages or URLs dynamically.</p> <h4>Security Considerations</h4> <p>Sanitizing strings before inserting them into the DOM or using them in queries prevents cross-site scripting (XSS) and injection attacks.</p> <h3>3.1.15 JS Numbers</h3> <p>Numbers in JavaScript handle calculations, timestamps, and counters, which are integral in cyber security analytics and monitoring tools.</p> <h4>Number Types and Precision</h4> <p>JavaScript uses floating-point numbers, which can lead to precision issues requiring careful handling in calculations like cryptographic algorithms.</p> <h4>Number Methods</h4> <p>Methods such as <strong>parseInt()</strong>, <strong>parseFloat()</strong>, <strong>toFixed()</strong>, and <strong>isNaN()</strong> assist in converting and validating numeric data.</p> <h4>Arithmetic Operations</h4> <p>Basic operations (+, -, *, /) and more complex math functions support data processing in security dashboards, for example, calculating login attempt thresholds.</p> <h4>Security Relevance</h4> <p>Validating numeric inputs prevents injection of malformed data and ensures system stability, especially in form fields like age or transaction amounts.</p> <h3>3.1.16 JS Arrays</h3> <p>Arrays store ordered collections of data, useful for managing lists of users, logs, or alerts in cyber security applications.</p> <h4>Array Creation and Properties</h4> <p>Arrays can be created using literals or constructors, with properties like <strong>length</strong> indicating the number of elements.</p> <h4>Common Array Methods</h4> <p>Methods such as <strong>push()</strong>, <strong>pop()</strong>, <strong>shift()</strong>, <strong>unshift()</strong>, <strong>splice()</strong>, and <strong>slice()</strong> enable adding, removing, and extracting elements.</p> <h4>Iterating Arrays</h4> <p>Iteration methods like <strong>forEach()</strong>, <strong>map()</strong>, and <strong>filter()</strong> allow efficient processing of array elements, for instance, filtering logs by severity.</p> <h4>Security Applications</h4> <p>Arrays help manage data sets securely by enabling controlled access and manipulation, vital in systems like SACCO membership management or hospital patient records.</p> <div class="practice-qs" style="background:#FFFBF0;border:1px solid #F0C040;border-left:5px solid #E8A020;border-radius:8px;padding:16px 20px;margin:20px 0 40px 0;"> <h3>Practice Questions</h3> <ol> <li>Explain the difference between the <b> and <strong> tags in HTML and discuss their implications for accessibility. (6 marks) </li> <li>Describe how the <a> tag’s target and rel attributes can be used to enhance web security. (6 marks) </li> <li>Outline the core HTML tags used to create a table and explain how improper sanitization of table content can lead to security vulnerabilities. (8 marks) </li> <li>Discuss why frames are deprecated in modern web design and how the <iframe> tag can introduce security risks if not properly managed. (6 marks) </li> <li>List and explain six common input types in HTML forms and their significance in client-side validation. (12 marks) </li> <li>Describe four types of JavaScript events and explain how event propagation affects event handling in web applications. (8 marks) </li> <li>Explain how JavaScript string manipulation methods can be used to prevent cross-site scripting attacks. (6 marks) </li> <li>Discuss the challenges of using JavaScript numbers in cryptographic calculations and how to mitigate precision errors. (6 marks) </li> <li>Describe five common JavaScript array methods and their applications in processing security-related data. (10 marks) </li> </ol> </div><div style="margin:26px 0 14px;display:flex;align-items:center;gap:12px;"><span style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#8C9BAC;">The rest of this chapter</span><span style="flex:1;height:1px;background:#E4E9EF;"></span></div><style>.tlock{margin:14px 0 22px;padding:18px 16px;border-radius:14px;border:1px solid #E8CFA0;text-align:center;background:linear-gradient(160deg,#FFFDF7 0%,#FFF6E6 100%);}.tlock-i{display:inline-flex;width:32px;height:32px;border-radius:50%;background:#fff;border:1px solid #E8CFA0;align-items:center;justify-content:center;font-size:15px;margin-bottom:8px;}.tlock-h{margin:0;font-size:15.5px;line-height:1.4;color:#5c3f06;font-weight:800;}.tlock-s{margin:6px auto 0;max-width:34em;font-size:13px;line-height:1.55;color:#8a5a00;}@media(min-width:560px){.tlock{padding:30px 26px;border-radius:18px;}.tlock-i{width:46px;height:46px;font-size:20px;margin-bottom:12px;}.tlock-h{font-size:18px;line-height:1.5;}.tlock-s{font-size:14.5px;line-height:1.7;margin-top:8px;}}</style><div class="tlock"><span class="tlock-i">&#128274;</span><p class="tlock-h">Create a free account to open more of this chapter.</p><p class="tlock-s">Free: practical guides, quick cards, workplace scenarios and more.</p><a href="/signup/" style="display:inline-block;margin-top:11px;padding:11px 22px;border-radius:999px;background:#C87F0A;color:#fff;text-decoration:none;font-weight:700;font-size:14px;">Create a free account</a></div><div style="position:relative;margin:0 0 14px;padding:20px 22px 30px;border:1px solid #E8EDF3;border-radius:16px;background:#fff;overflow:hidden;box-shadow:0 1px 3px rgba(26,47,74,.05);"><div style="display:flex;align-items:flex-start;gap:13px;margin-bottom:12px;"><span style="flex-shrink:0;width:34px;height:34px;border-radius:50%;background:#FCF3E4;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;">&#128274;</span><span style="font-size:17px;font-weight:700;color:#2C3E52;line-height:1.4;padding-top:5px;">3.2 SCRIPTING</span></div><p style="margin:0;font-size:15px;line-height:1.8;color:#93A0AF;">Scripting languages are essential tools in website design and development, especially within cyber security contexts where dynamic content and interactive user interfaces often require automation and client-server communication. In Kenya’s evolving digital eco&hellip;</p><div style="position:absolute;left:0;right:0;bottom:0;height:78px;background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.85) 45%,#fff 100%);"></div></div><div style="position:relative;margin:0 0 14px;padding:20px 22px 30px;border:1px solid #E8EDF3;border-radius:16px;background:#fff;overflow:hidden;box-shadow:0 1px 3px rgba(26,47,74,.05);"><div style="display:flex;align-items:flex-start;gap:13px;margin-bottom:12px;"><span style="flex-shrink:0;width:34px;height:34px;border-radius:50%;background:#FCF3E4;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;">&#128274;</span><span style="font-size:17px;font-weight:700;color:#2C3E52;line-height:1.4;padding-top:5px;">3.3 JAVA SCRIPTING</span></div><p style="margin:0;font-size:15px;line-height:1.8;color:#93A0AF;">JavaScript is the most widely used client-side scripting language for website development, offering extensive capabilities for creating interactive and secure web applications. For cyber security professionals in Kenya, mastering JavaScript is essential to imp&hellip;</p><div style="position:absolute;left:0;right:0;bottom:0;height:78px;background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.85) 45%,#fff 100%);"></div></div><div style="position:relative;margin:0 0 14px;padding:20px 22px 30px;border:1px solid #E8EDF3;border-radius:16px;background:#fff;overflow:hidden;box-shadow:0 1px 3px rgba(26,47,74,.05);"><div style="display:flex;align-items:flex-start;gap:13px;margin-bottom:12px;"><span style="flex-shrink:0;width:34px;height:34px;border-radius:50%;background:#FCF3E4;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;">&#128274;</span><span style="font-size:17px;font-weight:700;color:#2C3E52;line-height:1.4;padding-top:5px;">3.4 PHP</span></div><p style="margin:0;font-size:15px;line-height:1.8;color:#93A0AF;">PHP is a widely used server-side scripting language that plays a crucial role in developing dynamic and interactive websites. In Kenya's cyber security landscape, PHP is often employed to build secure web applications that handle sensitive data, such as online&hellip;</p><div style="position:absolute;left:0;right:0;bottom:0;height:78px;background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.85) 45%,#fff 100%);"></div></div><div style="position:relative;margin:0 0 14px;padding:20px 22px 30px;border:1px solid #E8EDF3;border-radius:16px;background:#fff;overflow:hidden;box-shadow:0 1px 3px rgba(26,47,74,.05);"><div style="display:flex;align-items:flex-start;gap:13px;margin-bottom:12px;"><span style="flex-shrink:0;width:34px;height:34px;border-radius:50%;background:#FCF3E4;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;">&#128274;</span><span style="font-size:17px;font-weight:700;color:#2C3E52;line-height:1.4;padding-top:5px;">3.5 Database Creation</span></div><p style="margin:0;font-size:15px;line-height:1.8;color:#93A0AF;">In cyber security-focused website development, creating a secure and efficient database is fundamental. Databases store sensitive information such as user credentials, access logs, and transaction records, which cyber security professionals must protect from u&hellip;</p><div style="position:absolute;left:0;right:0;bottom:0;height:78px;background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.85) 45%,#fff 100%);"></div></div><div style="position:relative;margin:0 0 14px;padding:20px 22px 30px;border:1px solid #E8EDF3;border-radius:16px;background:#fff;overflow:hidden;box-shadow:0 1px 3px rgba(26,47,74,.05);"><div style="display:flex;align-items:flex-start;gap:13px;margin-bottom:12px;"><span style="flex-shrink:0;width:34px;height:34px;border-radius:50%;background:#FCF3E4;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;">&#128274;</span><span style="font-size:17px;font-weight:700;color:#2C3E52;line-height:1.4;padding-top:5px;">3.6 Database Linkage</span></div><p style="margin:0;font-size:15px;line-height:1.8;color:#93A0AF;">Linking a website to its underlying database is a critical step that enables dynamic content delivery and user interaction. For cyber security professionals, ensuring this connection is secure and efficient is paramount to protect sensitive data and maintain s&hellip;</p><div style="position:absolute;left:0;right:0;bottom:0;height:78px;background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.85) 45%,#fff 100%);"></div></div><h2>Chapter Summary</h2> <p>This chapter focused on developing a functional website through a comprehensive understanding of HTML coding, including essential tags for formatting, hyperlinks, tables, frames, forms, and lists. It then explored scripting languages by examining their functions and various types, highlighting how they enhance web interactivity. JavaScript was detailed with key concepts such as events, string manipulation, numerical operations, and array handling to create dynamic user experiences. The chapter also covered PHP, emphasizing its importance in server-side scripting, with a thorough review of its syntax, variables, data types, operators, control structures, functions, arrays, and form handling capabilities. Furthermore, the creation of databases was addressed, explaining how structured data storage supports website functionality. Finally, the linkage between databases and web applications was discussed to enable seamless data retrieval and manipulation, completing the foundation for building interactive and data-driven websites.</p> <div id="sec-self-assessment" class="self-assessment" style="background:#EBF5FB;border:1px solid #2E86C1;border-left:5px solid #2E86C1;border-radius:8px;padding:20px 24px;margin:32px 0;"> <h2>Self-Assessment</h2><div style="background:#E8F5E9;border:1px solid #A5D6A7;border-radius:6px;padding:9px 14px;margin:14px 0 0;font-size:13px;font-weight:700;color:#1B5E20;overflow:hidden;"><a href='/chapter/52510/self-assessment/pdf/' target='_blank' rel='noopener' style='float:right;font-size:11px;font-weight:600;color:#1E8449;text-decoration:none;border:1px solid #A5D6A7;border-radius:5px;padding:2px 9px;margin-left:8px;'>&#128274; PDF</a>Download this self-assessment, with answers</div> <h3>A. Written Assessment</h3> <div class="practice-qs" style="background:#FFFBF0;border:1px solid #F0C040;border-left:5px solid #E8A020;border-radius:8px;padding:16px 20px;margin:20px 0 40px 0;"> <ol> <li>What is the purpose of the <code>&lt;form&gt;</code> tag in HTML and how is it important for cybersecurity professionals? (3 marks) </li> <li>Identify three formatting tags in HTML and explain how improper use can affect website security. (4 marks) </li> </ol> </div> <div style="margin:16px 0 4px;padding:14px 17px;background:#FFFDF7;border:1px solid #E8CFA0;border-radius:11px;display:flex;align-items:center;gap:11px;"><span style="flex-shrink:0;width:28px;height:28px;border-radius:50%;background:#fff;border:1px solid #E8CFA0;display:inline-flex;align-items:center;justify-content:center;font-size:13px;">&#128274;</span><span style="font-size:14.5px;line-height:1.6;color:#6b4a08;font-weight:600;">20 more in this section.</span></div> <p><div style="text-align:center;margin:2px 0 10px;"><a href="/signup/" style="display:inline-block;margin-top:11px;padding:7px 15px;border-radius:999px;background:#C87F0A;color:#fff;text-decoration:none;font-weight:700;font-size:12.5px;">Create a free account</a></p> </div> <h2>Chapter Examination Questions</h2><div style="background:#E8F5E9;border:1px solid #A5D6A7;border-radius:6px;padding:9px 14px;margin:14px 0 0;font-size:13px;font-weight:700;color:#1B5E20;overflow:hidden;"><a href='/chapter/52510/exam-questions/pdf/' target='_blank' rel='noopener' style='float:right;font-size:11px;font-weight:600;color:#1E8449;text-decoration:none;border:1px solid #A5D6A7;border-radius:5px;padding:2px 9px;margin-left:8px;'>&#128274; PDF</a>Download these examination questions, with worked answers</div> <h3>SECTION A (40 Marks) - Answer ALL Questions</h3> <ol> <li>Explain the role of HTML formatting tags in enhancing the readability of a cybersecurity awareness website hosted by the Kenya Cybersecurity Authority. (4 marks) </li> <li>Describe how hyperlink tags can be used to improve navigation within a cybersecurity portal for reporting cybercrimes. (4 marks) </li> </ol> <div style="margin:16px 0 4px;padding:14px 17px;background:#FFFDF7;border:1px solid #E8CFA0;border-radius:11px;display:flex;align-items:center;gap:11px;"><span style="flex-shrink:0;width:28px;height:28px;border-radius:50%;background:#fff;border:1px solid #E8CFA0;display:inline-flex;align-items:center;justify-content:center;font-size:13px;">&#128274;</span><span style="font-size:14.5px;line-height:1.6;color:#6b4a08;font-weight:600;">18 more in this section.</span></div> <div style="text-align:center;margin:2px 0 10px;"><a href="/signup/" style="display:inline-block;margin-top:11px;padding:7px 15px;border-radius:999px;background:#C87F0A;color:#fff;text-decoration:none;font-weight:700;font-size:12.5px;">Create a free account</a></div> </div> </div> <script id="sa-fc-data" type="application/json">[{"q": "What is the importance of HTML coding for cyber security professionals?", "points": ["Defines the structure and content of web pages.", "Helps identify potential injection points in web applications.", "Enables enforcing secure coding practices.", "Vital for protecting sensitive data in institutions like banks and government portals.", "Forms the basis for understanding web vulnerabilities and defenses."]}, {"q": "What are the differences between the HTML formatting tags \u003Cb\u003E and \u003Cstrong\u003E?", "points": ["Both make text bold visually.", "\u003Cstrong\u003E indicates semantic importance, recognized by search engines and screen readers.", "\u003Cb\u003E is purely stylistic without semantic meaning.", "\u003Cstrong\u003E improves accessibility for users with disabilities.", "Using \u003Cstrong\u003E enhances SEO by signaling important content."]}]</script> <script id="sa-mcq-data" type="application/json">[{"q": "What is the primary role of HTML in web development for cyber security professionals?", "why": "HTML defines the structure and content displayed by browsers.", "answer": 0, "options": ["Defines structure and content of web pages", "Encrypts user data", "Executes server-side scripts", "Manages database connections"]}, {"q": "Which HTML tag is used to create a paragraph and provide spacing between content?", "why": "\u003Cp\u003E defines paragraphs and spacing.", "answer": 1, "options": ["\u003Cspan\u003E", "\u003Cp\u003E", "\u003Csection\u003E", "\u003Cdiv\u003E"]}]</script> <div class="study-aids" style="margin-top:36px;"> <details class="sa-block" style="border:1px solid #B6D0EA;border-radius:14px;margin-bottom:18px;overflow:hidden;"> <summary style="cursor:pointer;list-style:none;padding:16px 20px;background:var(--primary);display:flex;align-items:center;gap:12px;"> <span style="font-size:16px;font-weight:600;color:#fff;">Flashcards</span> <span style="background:#fff;color:var(--primary);font-size:12px;font-weight:600;padding:3px 10px;border-radius:999px;">19 cards</span> <span style="margin-left:auto;font-size:13px;color:#CFE0F0;">Study deck &#9662;</span> </summary> <div style="padding:18px 18px 20px;"> <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;"> <div id="sa-fc-dots" style="display:flex;gap:5px;flex:1;"></div> <span id="sa-fc-count" style="font-size:13px;color:#6B7B8D;font-weight:600;"></span> </div> <div id="sa-fc-card" style="border:1.5px solid #2E86C1;border-radius:16px;background:#fff;cursor:pointer;min-height:190px;"> <div id="sa-fc-strip" style="background:#EBF2FA;padding:10px 18px;border-radius:14px 14px 0 0;"> <span id="sa-fc-side" style="font-size:11px;letter-spacing:.07em;text-transform:uppercase;font-weight:700;color:var(--primary);">Question</span> </div> <div style="padding:20px 20px 16px;"> <div id="sa-fc-front"> <div style="display:flex;gap:12px;align-items:flex-start;"> <div id="sa-fc-num" style="flex-shrink:0;width:32px;height:32px;border-radius:50%;background:#2E86C1;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:14px;">1</div> <p id="sa-fc-q" style="margin:0;font-size:17px;line-height:1.55;color:var(--primary);"></p> </div> </div> <div id="sa-fc-back" style="display:none;"> <div style="color:#1E8449;font-weight:700;font-size:13px;margin-bottom:10px;">Answer</div> <ul id="sa-fc-points" style="margin:0;padding-left:0;list-style:none;display:flex;flex-direction:column;gap:9px;"></ul> </div> </div> <div id="sa-fc-cue" style="background:#2E86C1;color:#fff;text-align:center;padding:9px;font-size:13px;font-weight:600;border-radius:0 0 14px 14px;">&#8635; Tap card to reveal answer</div> </div> <div style="display:flex;gap:10px;margin-top:14px;"> <button type="button" onclick="saFcPrev()" style="flex:1;padding:11px;border:1px solid #CBD5E0;border-radius:999px;background:#fff;color:var(--primary);font-weight:600;font-size:14px;cursor:pointer;">&#8592; Prev</button> <button type="button" onclick="saFcShuffle()" style="flex:1;padding:11px;border:1px solid #CBD5E0;border-radius:999px;background:#fff;color:var(--primary);font-weight:600;font-size:14px;cursor:pointer;">&#10797; Shuffle</button> <button type="button" onclick="saFcNext()" style="flex:1;padding:11px;border:none;border-radius:999px;background:var(--primary);color:#fff;font-weight:600;font-size:14px;cursor:pointer;">Next &#8594;</button> </div> <div style="margin:14px 0 2px;padding:12px 15px;border:1px solid #E0C88A;border-radius:16px;text-align:center;background:linear-gradient(160deg,#FFFDF7 0%,#FFF6E6 100%);"><span style="display:inline-flex;width:26px;height:26px;border-radius:50%;background:#fff;border:1px solid #E0C88A;align-items:center;justify-content:center;font-size:12px;margin-bottom:6px;">&#128274;</span><p style="margin:0;font-size:13.5px;line-height:1.5;color:#5c3f06;font-weight:700;">17 more in this section.</p><a href="/signup/" style="display:inline-block;margin-top:11px;padding:7px 15px;border-radius:999px;background:#C87F0A;color:#fff;text-decoration:none;font-weight:700;font-size:12.5px;">Create a free account</a></div> </div> </details> <details class="sa-block" style="border:1px solid #CFE6D9;border-radius:14px;overflow:hidden;"> <summary style="cursor:pointer;list-style:none;padding:16px 20px;background:#2E7D4F;display:flex;align-items:center;gap:12px;"> <span style="font-size:16px;font-weight:600;color:#fff;">Test Yourself</span> <span style="background:#fff;color:#2E7D4F;font-size:12px;font-weight:600;padding:3px 10px;border-radius:999px;">20 questions</span> <span style="margin-left:auto;font-size:13px;color:#CFE6D9;">Start quiz &#9662;</span> </summary> <div style="padding:18px;"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;"> <div style="flex:1;height:8px;background:#EAEDF0;border-radius:999px;overflow:hidden;"><div id="sa-quiz-bar" style="height:100%;width:0;background:#1E8449;transition:width .3s;"></div></div> <div style="text-align:right;min-width:54px;"> <div id="sa-quiz-pct" style="font-size:22px;font-weight:700;color:var(--primary);line-height:1;">0%</div> <div id="sa-quiz-score" style="font-size:12px;color:#6B7B8D;">0 / 2</div> </div> </div> <div id="sa-quiz-mount"></div> <div id="sa-quiz-done" style="display:none;text-align:center;padding:16px;border-radius:12px;margin-top:12px;font-weight:700;"></div> <div style="margin:14px 0 2px;padding:12px 15px;border:1px solid #E0C88A;border-radius:16px;text-align:center;background:linear-gradient(160deg,#FFFDF7 0%,#FFF6E6 100%);"><span style="display:inline-flex;width:26px;height:26px;border-radius:50%;background:#fff;border:1px solid #E0C88A;align-items:center;justify-content:center;font-size:12px;margin-bottom:6px;">&#128274;</span><p style="margin:0;font-size:13.5px;line-height:1.5;color:#5c3f06;font-weight:700;">18 more in this section.</p><a href="/signup/" style="display:inline-block;margin-top:11px;padding:7px 15px;border-radius:999px;background:#C87F0A;color:#fff;text-decoration:none;font-weight:700;font-size:12.5px;">Create a free account</a></div> </div> </details> </div> <!-- Edit Panel --> <style> /* Topic-note editor: keep toolbar pinned, scroll only the note text (like the flag editor) */ #notesEditorWrap .editor-toolbar { position:sticky; top:0; z-index:6; background:#fff; } #notesEditorWrap .CodeMirror-scroll { max-height:52vh; } /* Editor opened from a per-heading ✏ Edit: float it in place like the flag editor */ #editPanel.edit-floating, #practicalsPanel.edit-floating { position: fixed; top: 6vh; left: 50%; transform: translateX(-50%); width: min(900px, 94vw); max-height: 86vh; overflow-y: auto; background: #fff; border: 1px solid #D8E0E8; border-radius: 14px; box-shadow: 0 18px 60px rgba(20,40,70,0.28); z-index: 4000; margin: 0; padding: 20px 22px; } #editFloatBackdrop { display:none; position:fixed; inset:0; background:rgba(20,35,55,0.35); z-index:3999; } .img-delete-btn { display:none !important; } body.note-editing .img-delete-btn { display:block !important; } #editFloatBackdrop.on { display:block; } </style> <div id="editFloatBackdrop" onclick="closeFloatingEditor()"></div> <div id="editPanel" style="display:none;margin-top:32px;border-top:2px solid #E0E4E8;padding-top:24px;"> <h3 style="font-size:15px;color:var(--primary);margin-bottom:20px;">📝 Content Editor</h3> <!-- Theory Notes Editor --> <div style="margin-bottom:28px;"> <div style="background:#F0F6FC;border:1px solid var(--primary);border-radius:8px 8px 0 0;padding:10px 16px;display:flex;justify-content:space-between;align-items:center;"> <span style="font-weight:700;color:var(--primary);font-size:13px;">📖 Theory Notes (Full Chapter)</span> <div style="display:flex;gap:6px;"> <select id="topicSelector" style="font-size:12px;padding:4px 8px;border:1px solid #CBD5E0;border-radius:6px;" onchange="loadTopicNote(this.value)"> <option value="">-- Select Topic to Edit --</option> <option value="165183">3.1 HTML CODING</option> <option value="165190">3.2 SCRIPTING Functions of scripting langua…</option> <option value="165191">3.3 Java scripting</option> <option value="165196">3.4 PHP</option> <option value="165206">3.5 Database Creation</option> <option value="165207">3.6 Database Linkage</option> <option value="full">📄 Full Chapter Notes</option> </select> <button type="button" onclick="togglePreviewBtn(this)" id="previewBtn" title="See it as your students will" style="font-size:12px;padding:4px 14px;background:#fff;color:var(--primary);border:1.5px solid var(--primary);border-radius:6px;cursor:pointer;font-weight:700;">&#128065; Preview</button> <button onclick="saveNotes()" style="font-size:12px;padding:4px 14px;background:var(--primary);color:white;border:none;border-radius:6px;cursor:pointer;margin-left:6px;">💾 Save</button> <button onclick="closeFloatingEditor()" style="font-size:12px;padding:4px 12px;background:#fff;color:#6B7B8D;border:1.5px solid #CBD5E0;border-radius:6px;cursor:pointer;margin-left:6px;">✕ Close</button> </div> </div> <div style="border:1px solid var(--primary);border-top:none;border-radius:0 0 8px 8px;padding:12px;"> <div id="previewNudge" style="display:none;font-size:12.5px;color:#8A6D02; background:#FEF9EE;border-left:3px solid #E0C88A;padding:8px 12px; border-radius:0 6px 6px 0;margin-bottom:10px;"> <strong>Preview before you save</strong> &mdash; especially after changing a table or an image, so you see it exactly as your students will. </div> <div id="notesEditorWrap" style="display:none;"> <textarea id="notesEditor"></textarea> <div style="margin-top:8px;font-size:12px;color:#41556B;"> Markdown: <code>##</code> heading, <code>**bold**</code>, <code>![caption](url)</code> image. <span id="notesSaveStatus" style="margin-left:12px;font-weight:600;"></span> </div> </div> <p id="notesEditorPlaceholder" style="color:#888;font-size:12px;text-align:center;padding:20px;">Select a topic above to edit its notes.</p> </div> </div> <!-- Practice Questions --> <div style="margin-bottom:28px;"> <div style="background:#FFF8E1;border:1px solid #E0C88A;border-radius:8px 8px 0 0;padding:10px 16px;display:flex;justify-content:space-between;align-items:center;"> <span style="font-weight:700;color:var(--accent);font-size:13px;">📝 Practice Questions</span> <button onclick="addPracticeQuestion()" style="font-size:12px;padding:4px 12px;background:var(--accent);color:white;border:none;border-radius:6px;cursor:pointer;">+ Add</button> </div> <div id="practiceQsList" style="border:1px solid #E0C88A;border-top:none;border-radius:0 0 8px 8px;padding:12px;min-height:60px;"></div> </div> </div> <script> document.addEventListener('DOMContentLoaded', function () { var list = document.getElementById('cmpChecklist'); if (!list) return; var ticks = list.querySelectorAll('.cmp-tick'); var tally = document.getElementById('cmpTally'); var total = ticks.length; function refresh() { var done = list.querySelectorAll('.cmp-tick[aria-pressed="true"]').length; if (done === 0) { tally.textContent = 'Tick each ability you can genuinely do, just for yourself.'; tally.classList.remove('full'); } else if (done < total) { tally.textContent = done + ' of ' + total + '. Keep going, or decide below.'; tally.classList.remove('full'); } else { tally.textContent = 'You have reviewed all ' + total + '. Now make your honest call below.'; tally.classList.add('full'); } // ticking never pre-selects a declaration — the student always decides deliberately } list.querySelectorAll('.cmp-check').forEach(function (row) { var t = row.querySelector('.cmp-tick'); row.addEventListener('click', function () { var on = t.getAttribute('aria-pressed') === 'true'; t.setAttribute('aria-pressed', on ? 'false' : 'true'); row.classList.toggle('done', !on); refresh(); }); }); }); </script> <script> document.addEventListener('DOMContentLoaded', function () { var form = document.getElementById('cmpForm'); if (!form) return; var labels = { yes: 'Yes, I can do all of this', somewhat: 'Somewhat, I need more practice', no: 'Not yet, I need to go over it again' }; function csrf() { var m = document.cookie.match(/csrftoken=([^;]+)/); return m ? m[1] : (form.querySelector('[name=csrfmiddlewaretoken]') || {}).value; } form.addEventListener('submit', function (e) { e.preventDefault(); var btn = document.activeElement; var level = btn && btn.name === 'level' ? btn.value : null; if (!level) return; var body = 'level=' + encodeURIComponent(level) + '&csrfmiddlewaretoken=' + encodeURIComponent(csrf()); fetch(form.dataset.action, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, body: body }) .then(function () { // update highlight in place form.querySelectorAll('.cmp-btn').forEach(function (b) { b.classList.toggle('on', b.value === level); }); // update / create the "You said" banner var pad = form.closest('.cmp-pad'); var cur = pad.querySelector('.cmp-current'); if (!cur) { cur = document.createElement('div'); form.parentNode.insertBefore(cur, form); } cur.className = 'cmp-current cmp-' + level; var now = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); cur.innerHTML = '<span class="cmp-current-label">Saved &#10003; &mdash; you said</span> ' + labels[level] + ' <span class="cmp-when">&middot; ' + now + '</span>'; // contextual follow-up under the buttons var follow = { yes: 'Nicely done. When you\'re ready, move on to the next chapter below.', somewhat: 'Good honesty. Work through the tools above once more, then come back and update this.', no: 'That\'s an honest place to start. Use the tools above to build up, then return and try again.' }; var fEl = pad.querySelector('.cmp-followup'); if (!fEl) { fEl = document.createElement('p'); fEl.className = 'cmp-followup'; form.parentNode.insertBefore(fEl, form.nextSibling); } fEl.className = 'cmp-followup cmp-follow-' + level; fEl.textContent = follow[level] || ''; }) .catch(function () { form.submit(); }); // fallback: normal post if fetch fails }); }); </script> <section class="cmp" id="competency"> <div class="cmp-head">Am I competent?</div> <div class="cmp-pad"> <!-- 1. THE PROMISE — check yourself against each outcome --> <p class="cmp-lead">At the start of this chapter we promised you would be able to:</p> <ul class="cmp-checklist" id="cmpChecklist"> <li class="cmp-check" data-i="0"> <button type="button" class="cmp-tick" aria-pressed="false"><span></span></button> <span class="cmp-check-text">Ensure the website meets all functional requirements like working navigation, form submissions, and media loading during testing.</span> </li> <li class="cmp-check" data-i="1"> <button type="button" class="cmp-tick" aria-pressed="false"><span></span></button> <span class="cmp-check-text">Test and confirm that all web pages load quickly, aiming for under three seconds on normal internet speeds.</span> </li> <li class="cmp-check" data-i="2"> <button type="button" class="cmp-tick" aria-pressed="false"><span></span></button> <span class="cmp-check-text">Create a fully responsive website design that works smoothly on at least three different devices or screen sizes.</span> </li> <li class="cmp-check" data-i="3"> <button type="button" class="cmp-tick" aria-pressed="false"><span></span></button> <span class="cmp-check-text">Pass mobile-friendly tests without any layout problems to provide a great user experience.</span> </li> </ul> <p class="cmp-tally" id="cmpTally">Tick each one you can genuinely do.</p> <!-- Evidence: best scores across simulations + persisted self-tests --> <!-- 4. DECLARE: the informed self-declaration --> <p class="cmp-ask">So, are you there yet?</p> <p class="cmp-threshold">You're competent when you can confidently do <strong>50% or more</strong> of what this chapter promised.</p> <p class="cmp-priv"><a href="/login/">Sign in</a> to record how you're doing.</p> </div> </section> <style> .cmp { margin: 34px 0 0; border: 1.5px solid var(--primary); border-radius: 14px; background: #fff; overflow: hidden; } /* ---- capstone slots (checklist / test / prove) ---- */ .cmp-checklist { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 8px; } .cmp-check { display: flex; align-items: center; gap: 13px; padding: 12px 14px; border: 1.5px solid #E4EAF0; border-radius: 11px; background: #fff; cursor: pointer; transition: border-color .15s, background .15s; } .cmp-check:hover { border-color: #C3D4E4; } .cmp-check.done { border-color: #BFE3CD; background: #F4FAF6; } .cmp-tick { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 8px; border: 2px solid #C3D0DC; background: #fff; cursor: pointer; padding: 0; display: flex; align-items: center; justify-content: center; transition: background .18s, border-color .18s, transform .12s; } .cmp-check:active .cmp-tick { transform: scale(.92); } .cmp-tick span { width: 12px; height: 6.5px; border-left: 2.5px solid #fff; border-bottom: 2.5px solid #fff; transform: rotate(-45deg) translate(0, -1px); opacity: 0; transition: opacity .15s; } .cmp-tick[aria-pressed="true"] { background: #1E8449; border-color: #1E8449; } .cmp-tick[aria-pressed="true"] span { opacity: 1; } .cmp-check-text { font-size: 14.5px; line-height: 1.45; color: var(--primary); font-weight: 500; } .cmp-check.done .cmp-check-text { color: #1E6E43; } .cmp-tally { font-size: 13px; color: #8C9BAC; font-weight: 600; margin: 10px 2px 20px; } .cmp-tally.full { color: #1E8449; } .cmp-sim-sample { font-size: 12.5px; line-height: 1.5; color: var(--accent); background: #FEF9EE; border: 1px solid #E0C88A; border-radius: 8px; padding: 9px 12px; margin: 0 0 10px; } .cmp-slot { border: 1px solid #E1E7EE; border-radius: 12px; margin: 0 0 14px; overflow: hidden; background: #FbFCFD; } .cmp-slot-head { padding: 13px 16px; font-size: 14px; font-weight: 700; color: var(--primary); cursor: pointer; list-style: none; display: flex; align-items: center; gap: 9px; } .cmp-slot-head.static { cursor: default; } .cmp-slot-head::-webkit-details-marker { display: none; } .cmp-slot-icon { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; background: #EBF2FA; color: var(--primary); font-size: 12px; font-weight: 800; display: flex; align-items: center; justify-content: center; } .cmp-slot-icon.prove { background: #E9F6EE; color: #1E8449; } .cmp-slot-count { margin-left: auto; font-size: 11px; font-weight: 600; color: #8C9BAC; } .cmp-slot-body { padding: 4px 16px 16px; } .cmp-qs { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 9px; } .cmp-q { font-size: 13.5px; line-height: 1.5; color: #41556B; } .cmp-q-type { display: inline-block; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: #6B7B8D; background: #EBF2FA; border-radius: 4px; padding: 1px 6px; margin-right: 7px; vertical-align: middle; } .cmp-answers { margin-top: 12px; border-top: 1px solid #EDF1F5; padding-top: 10px; } .cmp-answers summary { font-size: 13px; font-weight: 700; color: var(--primary); cursor: pointer; } .cmp-answers-body { font-size: 13.5px; line-height: 1.65; color: #41556B; margin-top: 10px; } .cmp-prove { background: #F5FBF7; border-color: #CDE9D8; } .cmp-evidence { border: 1.5px solid #E4EAF0; border-radius: 12px; padding: 14px 16px; margin: 0 0 16px; background: #FbFCFD; } .cmp-evidence-title { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: #6B7B8D; font-weight: 700; margin-bottom: 12px; } .cmp-ev-row { display: flex; align-items: center; gap: 12px; margin-bottom: 9px; } .cmp-ev-label { flex: 0 0 42%; font-size: 13.5px; color: var(--primary); font-weight: 500; } .cmp-ev-bar { flex: 1; height: 8px; background: #E9EDF2; border-radius: 999px; overflow: hidden; } .cmp-ev-bar i { display: block; height: 100%; background: #1E8449; border-radius: 999px; } .cmp-ev-val { flex: 0 0 auto; font-size: 12.5px; font-weight: 700; color: #1E8449; min-width: 48px; text-align: right; } .cmp-ev-note { font-size: 12px; color: #8C9BAC; margin: 8px 0 0; line-height: 1.5; } .cmp-sim-best { font-size: 12.5px; font-weight: 700; color: #1E8449; margin: 2px 0 10px; } .cmp-sim-launch { display: flex; align-items: center; justify-content: space-between; gap: 10px; text-decoration: none; background: #1E8449; color: #fff; border-radius: 10px; padding: 14px 18px; font-size: 15px; font-weight: 700; margin-bottom: 8px; min-height: 50px; } .cmp-sim-launch:hover { background: #196B3A; } .cmp-sim-go { font-size: 13px; opacity: .9; white-space: nowrap; } .cmp-sim-soon { font-size: 13.5px; color: #6B7B8D; margin: 4px 0; } .ct-bar { scroll-margin-top: 80px; } .cmp-check-first { background: #FbFCFD; } .cmp-check-lead { font-size: 13.5px; color: #41556B; margin: 0 0 10px; } .cmp-tools { display: flex; flex-wrap: wrap; gap: 8px; } .cmp-tool { display: inline-block; font-size: 13px; font-weight: 600; color: var(--primary); background: #EBF2FA; border: 1px solid #D3E1F0; border-radius: 999px; padding: 7px 14px; text-decoration: none; } .cmp-tool:hover { background: #DCE9F6; border-color: #B9D0E8; } .cmp-threshold { font-size: 13px; color: #41556B; margin: 2px 0 12px; } .cmp-followup { font-size: 13.5px; line-height: 1.5; border-radius: 10px; padding: 11px 14px; margin: 12px 0 0; font-weight: 500; } .cmp-follow-yes { background: #F0F6F2; color: #1E6E43; border: 1px solid #C5E4D2; } .cmp-follow-somewhat { background: #FEF9EE; color: var(--accent); border: 1px solid #E0C88A; } .cmp-follow-no { background: #FDF3F2; color: #9C3A32; border: 1px solid #F1CFCB; } .cmp-head { padding: 14px 22px; background: var(--primary); color: #fff; font-size: 15px; font-weight: 700; letter-spacing: .04em; } .cmp-pad { padding: 20px 22px 22px; } .cmp-lead { margin: 0 0 12px; font-size: 14px; color: #6B7B8D; } .cmp-list { margin: 0 0 18px; padding-left: 0; list-style: none; } .cmp-list li { position: relative; padding: 7px 0 7px 30px; font-size: 15px; line-height: 1.6; color: var(--primary); } .cmp-list li::before { content: '\2713'; position: absolute; left: 6px; top: 7px; color: #2E86C1; font-weight: 700; } .cmp-fallback { margin-bottom: 18px; font-size: 15px; line-height: 1.7; color: var(--primary); } .cmp-ask { margin: 0 0 14px; font-size: 16px; font-weight: 700; color: var(--primary); } .cmp-current { margin: 0 0 14px; padding: 11px 14px; border-radius: 8px; font-size: 14px; font-weight: 600; color: var(--primary); background: #F4F7FA; border-left: 3px solid #8C9BAC; } .cmp-current-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.4px; color: #8C9BAC; font-weight: 700; margin-right: 6px; } .cmp-when { font-weight: 400; color: #8C9BAC; font-size: 12px; } .cmp-yes.on, .cmp-current.cmp-yes { border-color: #1E8449; } .cmp-current.cmp-yes { background: #F0F6F2; border-left-color: #1E8449; } .cmp-current.cmp-somewhat { background: #FDF6E8; border-left-color: var(--accent); } .cmp-current.cmp-no { background: #FBEEEC; border-left-color: #943126; } .cmp-form { display: flex; gap: 10px; flex-wrap: wrap; margin: 0; } /* phone-first: wide tables scroll sideways within the column, not the whole page */ /* A real table on desktop so it fills the column. display:block was applied at every width for phone side-scrolling, but it stops the element behaving as a table — it shrinks to its content and leaves dead space on the right. Scrolling is phone-only. */ .notes-body table { display: table; width: 100%; max-width: 100%; border-collapse: collapse; } @media (max-width: 820px) { .notes-body table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; font-size: 13px; } .notes-body table th, .notes-body table td { white-space: nowrap; padding: 8px 10px; } .notes-body img, .notes-body .svg-diagram, .notes-body pre { max-width: 100%; } .notes-body pre { overflow-x: auto; } } .cmp-btn { flex: 1; min-width: 165px; padding: 14px 16px; text-align: left; cursor: pointer; font-family: inherit; font-size: 15px; font-weight: 700; color: var(--primary); background: #fff; border: 1.5px solid #E0E6ED; border-radius: 10px; transition: border-color .15s, background .15s; } .cmp-btn span { display: block; margin-top: 3px; font-size: 12px; font-weight: 400; color: #6B7B8D; } .cmp-yes:hover, .cmp-yes.on { border-color: #1E8449; background: #F4FAF6; } .cmp-somewhat:hover, .cmp-somewhat.on { border-color: var(--accent); background: #FEFAF3; } .cmp-no:hover, .cmp-no.on { border-color: #943126; background: #FDF7F6; } .cmp-btn.on { box-shadow: inset 0 0 0 2px currentColor; font-weight: 700; } .cmp-yes.on { color: #1E8449; } .cmp-somewhat.on { color: var(--accent); } .cmp-no.on { color: #943126; } .cmp-priv { margin: 14px 0 0; font-size: 12px; color: #8C9BAC; } #competency { scroll-margin-top: 90px; } </style> <div class="chapter-nav"> <a href="/chapter/52509/">← Ch 2: Determine website design tools</a> <a href="/chapter/52511/">Ch 4: Host Website developed →</a> </div> </div> </div> </div> </div> <!-- Notes Editor Section (inside edit panel, before modal) --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css"> <script> window.MathJax = { tex: { inlineMath: [['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']], processEscapes: true }, options: { skipHtmlTags: ['script','noscript','style','textarea','pre','code'] }, svg: { fontCache: 'global' } }; </script> <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" id="MathJax-script" async></script> <script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script> <!-- Modal --> <div id="editModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:4100;align-items:center;justify-content:center;"> <div style="background:white;border-radius:12px;padding:24px;width:90%;max-width:640px;max-height:85vh;overflow-y:auto;position:relative;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> <h3 id="modalTitle" style="font-size:15px;font-weight:700;color:var(--primary);margin:0;"></h3> <button onclick="closeModal()" style="border:none;background:none;font-size:22px;cursor:pointer;color:#888;line-height:1;">×</button> </div> <div id="modalBody"></div> <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px;border-top:1px solid #E0E4E8;padding-top:16px;"> <button onclick="closeModal()" style="padding:8px 20px;border:1px solid #CBD5E0;border-radius:6px;background:white;cursor:pointer;">Cancel</button> <button id="modalSaveBtn" style="padding:8px 20px;border:none;border-radius:6px;background:var(--primary);color:white;cursor:pointer;font-weight:600;">Save</button> </div> </div> </div> <style> .stc-controls { font-size:0; white-space:nowrap; } .stc-btn { font-family:inherit; font-size:11px; font-weight:600; line-height:1; padding:3px 8px; border-radius:6px; cursor:pointer; margin-left:4px; vertical-align:middle; border:1px solid; } .stc-flag { background:#FDF3F1; color:#B23A2E; border-color:#E9C4BE; } .stc-flag:hover { background:#F9E3DF; } .stc-edit { background:#EEF3F8; color:var(--primary); border-color:#C2D5E8; } .stc-edit:hover { background:#DFE9F3; } @media print { .stc-controls { display:none !important; } } </style> <script> const CHAPTER_ID = 52510; const CAN_EDIT = false; // Deep-link: scroll to the actual CONTENT heading for a topic (not the sidebar item) (function(){ function scrollToHashTopic(){ var m = (location.hash||'').match(/sidebar-topic-(\d+)/); if(!m) return; var tid = m[1]; // find the injected control button carrying this topic id, then its heading var btn = document.querySelector('.notes-body [onclick*="stcFlag('+tid+',"], .notes-body [onclick*="stcEdit('+tid+',"]'); var target = null; if(btn){ target = btn.closest('h2,h3') || btn.parentElement; } if(!target){ // fallback: the sidebar item target = document.getElementById('sidebar-topic-'+tid); } if(target && target.scrollIntoView){ target.scrollIntoView({behavior:'smooth', block:'center'}); // brief highlight so the spot is obvious var h = target.closest('h2,h3') || target; if(h){ h.style.transition='background .3s'; var o=h.style.background; h.style.background='#FBF3DC'; setTimeout(function(){ h.style.background=o||''; }, 1800); } } } if(document.readyState!=='loading') setTimeout(scrollToHashTopic, 400); else document.addEventListener('DOMContentLoaded', function(){ setTimeout(scrollToHashTopic, 400); }); })(); // ---- Per-heading flag/edit controls ---- // Flag: opens the flag modal pre-filled with this subtopic's location. window._stcFlagTopicId = null; window._stcFlagNumber = null; window.stcFlag = function(topicId, number){ window._stcFlagTopicId = topicId; window._stcFlagNumber = number; if (typeof openFlagModal === 'function') { openFlagModal(); setTimeout(function(){ var loc = document.getElementById('flagLoc'); if (loc) loc.value = number; // ALWAYS set to THIS heading (was: only if empty) var d = document.getElementById('flagDesc'); if (d) { d.value = ''; d.focus(); } }, 30); } }; // Edit: opens THIS topic's note in the existing per-topic editor (reuses edit_topic_note). window.closeFloatingEditor = function(){ var _pp = document.getElementById('practicalsPanel'); if (_pp) { _pp.classList.remove('edit-floating'); _pp.style.display = 'none'; } var panel = document.getElementById('editPanel'); if(panel){ panel.classList.remove('edit-floating'); panel.style.display='none'; } var bd = document.getElementById('editFloatBackdrop'); if(bd) bd.classList.remove('on'); var pp = document.getElementById('practicalsPanel'); if(pp){ pp.classList.remove('edit-floating'); pp.style.display='none'; } // exit edit mode so per-heading edit controls + image Remove buttons hide again if (typeof editMode !== 'undefined' && editMode && typeof toggleEditMode === 'function') { toggleEditMode(); } // hard-hide any image delete buttons document.body.classList.remove('edit-mode-on'); document.body.classList.remove('note-editing'); }; // Renders the EasyMDE preview, then runs MathJax over it so an editor can VERIFY // formulas before saving. Display only -- the stored markdown is never modified. window.mdeMathPreview = function(plainText, preview) { // Markdown eats the backslash in \( ... \), so inline maths loses its delimiters // before MathJax ever sees it. Lift every maths span out to a plain-text placeholder, // run markdown, then put the spans back untouched. var store = []; var guarded = plainText.replace( /\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g, function (m) { store.push(m); return 'MJXMATH' + (store.length - 1) + 'HTAMJXM'; }); var html; try { html = this.parent.markdown(guarded); } catch (e) { html = guarded; } html = html.replace(/MJXMATH(\d+)HTAMJXM/g, function (_, i) { return store[Number(i)] || ''; }); preview.innerHTML = html; // EasyMDE writes the RETURNED html into the pane after this call, which would wipe // any typesetting done here. So typeset on the next tick, once the DOM has settled. if (window.MathJax && MathJax.typesetPromise) { setTimeout(function(){ var pane = document.querySelector('.editor-preview-active') || document.querySelector('.editor-preview-active-side') || preview; MathJax.typesetPromise([pane]).catch(function(){}); }, 60); } return html; }; // Toolbar button: wrap the selection (or insert a stub) in display-math delimiters. window.mdeMathButton = { name: 'math', className: 'fa', title: 'Insert formula ($$...$$)', action: function(editor) { var cm = editor.codemirror; var sel = cm.getSelection(); if (sel) { cm.replaceSelection('$$\n' + sel + '\n$$'); } else { var pos = cm.getCursor(); cm.replaceSelection('$$\n\n$$'); cm.setCursor({line: pos.line + 1, ch: 0}); } cm.focus(); } }; window.closeAllPanels = function(){ var ep = document.getElementById('editPanel'); if (ep) { ep.classList.remove('edit-floating'); ep.style.display = 'none'; } var pp = document.getElementById('practicalsPanel'); if (pp) { pp.classList.remove('edit-floating'); pp.style.display = 'none'; } var bd = document.getElementById('editFloatBackdrop'); if (bd) bd.classList.remove('on'); document.body.classList.remove('note-editing'); }; window.stcEditPracticals = function(){ closeAllPanels(); // Practicals are row-backed: open the editor panel and scroll to the practicals list. if (typeof editMode !== 'undefined' && !editMode && typeof toggleEditMode === 'function') { toggleEditMode(); } var pp = document.getElementById('practicalsPanel'); if (pp) { pp.style.display = 'block'; pp.classList.add('edit-floating'); var bd = document.getElementById('editFloatBackdrop'); if (bd) bd.classList.add('on'); } if (typeof loadPracticals === 'function') loadPracticals(); }; window.stcEditSection = function(key){ // Chapter-level section (Self-Assessment / Exam Questions). Loads the blob section // through the section endpoint into the SAME floating editor used for topic notes. closeAllPanels(); var wrap = document.getElementById('notesEditorWrap'); var ph = document.getElementById('notesEditorPlaceholder'); var st = document.getElementById('notesSaveStatus'); var panel = document.getElementById('editPanel'); if (typeof editMode !== 'undefined' && !editMode && typeof toggleEditMode === 'function') { toggleEditMode(); } var sel = document.getElementById('topicSelector'); if (sel) { sel.value = ''; } if (ph) ph.style.display = 'none'; if (wrap) wrap.style.display = 'block'; if (st) { st.textContent = 'Loading...'; st.style.color = '#888'; } if (panel) { panel.style.display = 'block'; panel.classList.add('edit-floating'); var bd = document.getElementById('editFloatBackdrop'); if (bd) bd.classList.add('on'); } document.body.classList.add('note-editing'); fetch('/api/chapter/' + CHAPTER_ID + '/section/' + key + '/') .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) .then(function(res){ if (!res.ok) { alert(res.d.error || 'Could not open this section.'); if (typeof closeFloatingEditor === 'function') closeFloatingEditor(); return; } currentNoteType = 'section'; currentSectionKey = key; currentTopicId = null; if (mde) { mde.toTextArea(); mde = null; } var w = document.getElementById('notesEditorWrap'); var old = w.querySelector('textarea'); if (old) old.remove(); var ta = document.createElement('textarea'); ta.id = 'notesEditor'; ta.value = res.d.content_md || ''; w.insertBefore(ta, w.firstChild); mde = new EasyMDE({ element: ta, spellChecker: false, autosave: { enabled: false }, previewRender: window.mdeMathPreview, toolbar: ['bold','italic','heading','|','quote','unordered-list','ordered-list','|', 'link','table','|','preview','guide'] }); if (st) { st.textContent = 'Editing: ' + (res.d.label || key); st.style.color = 'var(--primary)'; } if (typeof loadPracticeQuestions === 'function') loadPracticeQuestions(); }) .catch(function(){ alert('Network error loading section.'); if (typeof closeFloatingEditor === 'function') closeFloatingEditor(); }); }; window.stcEdit = function(topicId, number){ closeAllPanels(); // 1. Make sure edit mode is ON so the editor panel is visible if (typeof editMode !== 'undefined' && !editMode && typeof toggleEditMode === 'function') { toggleEditMode(); } // 2. Select this topic in the topic selector and load its note into the editor var sel = document.getElementById('topicSelector'); if (sel) { sel.value = topicId; if (typeof loadTopicNote === 'function') { loadTopicNote(topicId); } } // 3. Show the editor as a fixed overlay near the top of the viewport (like the flag // editor) so it opens IN PLACE — no jarring jump to the bottom of the page. var panel = document.getElementById('editPanel'); if (panel) { panel.style.display = 'block'; panel.classList.add('edit-floating'); var bd = document.getElementById('editFloatBackdrop'); if(bd) bd.classList.add('on'); // focus the editor without moving the page setTimeout(function(){ var cm = panel.querySelector('.CodeMirror'); if (cm && cm.CodeMirror) { cm.CodeMirror.focus(); } }, 200); } }; // ---- In-context flag modal (verified trainers) ---- function _flagCsrf(){ var m=document.cookie.match(/csrftoken=([^;]+)/); return m?m[1]:''; } window.openFlagModal = function(){ // REBUILD every time. Reusing the existing overlay meant that after one // successful flag - which REPLACES the overlay's innerHTML with the success // card - every later Flag button on the page reopened that stale card. The // trainer read "Flag submitted" and nothing was posted at all. It also left // the location label showing the PREVIOUS topic, so a flag that did submit // could be recorded against the wrong section. var ex = document.getElementById('flagModalOverlay'); if(ex){ ex.remove(); } if(window._flagMde){ try{ window._flagMde.toTextArea(); }catch(e){} window._flagMde=null; } if(!document.getElementById('flagModalStyle')){ var st=document.createElement('style'); st.id='flagModalStyle'; st.textContent= '#flagModalOverlay{position:fixed;inset:0;background:rgba(20,32,48,.5);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto;box-sizing:border-box;}' + '#flagModalCard{background:#fff;border-radius:16px;width:100%;max-width:520px;box-sizing:border-box;box-shadow:0 20px 60px rgba(0,0,0,.3);max-height:90vh;display:flex;flex-direction:column;overflow:hidden;}' + '#flagModalCard *{box-sizing:border-box;}' + '.fm-head{padding:16px 20px 10px;cursor:move;flex:0 0 auto;border-bottom:1px solid #F0F3F6;}' + '.fm-title-row{display:flex;align-items:center;justify-content:space-between;gap:10px;}' + '.fm-title{font-size:17px;font-weight:800;color:var(--primary);}' + '.fm-x{border:none;background:#F0F3F6;color:#41556B;width:32px;height:32px;border-radius:8px;font-size:19px;line-height:1;cursor:pointer;flex:0 0 auto;}' + '.fm-intro{font-size:12.5px;color:#6B7B8D;margin-top:5px;line-height:1.5;}' + '.fm-draghint{font-size:11px;color:#8C9BAC;margin-top:6px;}' + '.fm-body{padding:14px 20px;overflow-y:auto;flex:1 1 auto;-webkit-overflow-scrolling:touch;}' + '.fm-body .editor-toolbar{position:sticky;top:0;z-index:5;background:#fff;}' + '.fm-body .CodeMirror{min-height:120px;}' + '.fm-body .CodeMirror-scroll{max-height:38vh;}' + '.fm-body label{display:block;font-size:12px;font-weight:700;color:var(--primary);margin-bottom:5px;}' + '.fm-body input[type=text],.fm-body textarea{width:100%;padding:12px;border:1.5px solid #CBD5E0;border-radius:9px;font:inherit;font-size:15px;margin-bottom:14px;}' + '.fm-body textarea{resize:vertical;min-height:96px;}' + '.fm-check{display:flex;align-items:center;gap:10px;font-size:14px;color:#41556B;margin-bottom:2px;}' + '.fm-check input{width:20px;height:20px;flex:0 0 auto;margin:0;}' + '.fm-foot{padding:12px 20px;border-top:1px solid #F0F3F6;display:flex;gap:10px;justify-content:flex-end;flex:0 0 auto;background:#fff;}' + '.fm-btn{padding:12px 20px;border-radius:9px;font:inherit;font-weight:700;cursor:pointer;}' + '.fm-cancel{border:1.5px solid #CBD5E0;background:#fff;color:#41556B;font-weight:600;}' + '.fm-submit{border:none;background:#B23A2E;color:#fff;}' + '@media (max-width:640px){#flagModalOverlay{align-items:flex-start;padding:8px;}#flagModalCard{max-width:none;max-height:94vh;}.fm-head{cursor:default;}.fm-draghint{display:none;}}'; document.head.appendChild(st); } var o=document.createElement('div'); o.id='flagModalOverlay'; o.innerHTML= '<div id="flagModalCard">' + '<div class="fm-head" id="flagModalHeader">' + '<div class="fm-title-row"><div class="fm-title">Flag content</div>' + '<button type="button" class="fm-x" onclick="closeFlagModal()" aria-label="Close">&times;</button></div>' + '<div class="fm-intro">Report a mistake, omission, shallow content, or suggest an addition or figure. Accepted flags earn credits.</div>' + '<div class="fm-intro" style="background:#F0F9F2;border:1px solid #A9E3C4;border-radius:9px;padding:9px 12px;color:#1A5C36;">' + '<strong>Have the diagram, photo or rewritten section already?</strong> Add it below. ' + 'A suggestion we can use as it is gets accepted faster.</div>' + '<div class="fm-draghint">&#9995; Drag this bar to move &mdash; peek at the content behind.</div>' + '</div>' + '<div class="fm-body">' + '<label>Flagging</label>' + '<div id="flagLoc" style="background:#F4F7FA;color:var(--primary);font-weight:600;padding:10px 12px;border:1px solid #CBD5E0;border-radius:8px;font-size:13.5px;line-height:1.45;word-break:break-word;"></div>' + '<label>What needs attention?</label>' + '<textarea id="flagDesc" placeholder="Describe the mistake, omission, or suggestion. You can format text, add images/diagrams, or attach a Word/PDF file below."></textarea>' + '<div id="flagAttachRow" style="margin-top:6px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">' + '<button type="button" onclick="document.getElementById(\'flagFileInput\').click()" style="background:#EEF3F8;color:var(--primary);border:1.5px solid #C2D5E8;font:inherit;font-weight:600;font-size:13px;padding:8px 14px;border-radius:8px;cursor:pointer;">📎 Attach a file (Word / PDF)</button>' + '<input type="file" id="flagFileInput" accept=".doc,.docx,.pdf,.odt,.rtf,.txt,.ppt,.pptx,.xls,.xlsx,image/*" style="display:none;" onchange="uploadFlagAttachment(this)">' + '<span id="flagAttachName" style="font-size:13px;color:#1E8449;font-weight:600;"></span>' + '</div>' + '</div>' + '<div class="fm-foot">' + '<button type="button" class="fm-btn fm-cancel" onclick="closeFlagModal()">Cancel</button>' + '<button type="button" class="fm-btn fm-submit" id="flagSubmitBtn" onclick="submitFlag()">Submit flag</button>' + '</div>' + '</div>'; document.body.appendChild(o); // Mount a lightweight markdown editor on the flag description (toolbar kept minimal for phones) // A LIST since 16 Sep. One slot meant a second upload silently replaced the // first, and nothing could be removed once chosen. window._flagAttach = []; var _isPhoneEd = window.matchMedia('(max-width:640px)').matches; // hidden image input (used by both the EasyMDE toolbar and the phone image button) if (!document.getElementById('flagImgInput')) { var imgIn = document.createElement('input'); imgIn.type='file'; imgIn.id='flagImgInput'; imgIn.accept='image/*'; imgIn.style.display='none'; imgIn.onchange = function(){ uploadFlagImage(this); }; document.body.appendChild(imgIn); } // Show WHAT is being flagged. This used to sit inside the block above, which // only runs the FIRST time the hidden image input is created - so on a second // flag the label still named the previous topic. var locEl = document.getElementById('flagLoc'); if (locEl) { locEl.textContent = window._flagLocLabel || (window._stcFlagNumber ? ('Topic ' + window._stcFlagNumber) : '') || 'Whole page'; } // Desktop: rich EasyMDE editor. Phone: reliable plain textarea + a simple image button // (EasyMDE's CodeMirror is finicky in a small modal, so we keep phone lightweight). if (window.EasyMDE && !_isPhoneEd) { try { if (window._flagMde) { try { window._flagMde.toTextArea(); } catch(e){} window._flagMde = null; } var _imgBtn = {name:'image', className:'fa fa-picture-o', title:'Upload image/diagram', action: function(ed){ document.getElementById('flagImgInput').click(); }}; window._flagMde = new EasyMDE({ element: document.getElementById('flagDesc'), spellChecker: false, status: false, minHeight: '200px', placeholder: 'Describe your suggestion. Format text, add an image/diagram, or attach a file below.', toolbar: ['bold','italic','heading','|','unordered-list','ordered-list','quote','|', _imgBtn,'link','table','|','preview','guide'] }); } catch(e) { window._flagMde = null; } } else { // phone: no EasyMDE — plain textarea stays. Add a simple "Add image" button. window._flagMde = null; if (!document.getElementById('flagPhoneImgBtn')) { var pd = document.getElementById('flagDesc'); if (pd && pd.parentNode) { var b = document.createElement('button'); b.type='button'; b.id='flagPhoneImgBtn'; b.textContent='🖼 Add image/diagram'; b.style.cssText='margin-top:8px;background:#EEF3F8;color:var(--primary);border:1.5px solid #C2D5E8;font:inherit;font-weight:600;font-size:13px;padding:9px 14px;border-radius:8px;cursor:pointer;'; b.onclick=function(){ document.getElementById('flagImgInput').click(); }; pd.parentNode.insertBefore(b, pd.nextSibling); } } } o.addEventListener('click', function(e){ if(e.target===o) closeFlagModal(); }); if(!window.matchMedia('(max-width:640px)').matches){ var card=document.getElementById('flagModalCard'), handle=document.getElementById('flagModalHeader'); var dragging=false,sx=0,sy=0,ox=0,oy=0; function down(e){ if(e.target.tagName==='BUTTON')return; dragging=true; var pt=e.touches?e.touches[0]:e; sx=pt.clientX; sy=pt.clientY; var r=card.getBoundingClientRect(); ox=r.left; oy=r.top; o.style.alignItems='flex-start'; o.style.justifyContent='flex-start'; card.style.transform='translate('+ox+'px,'+oy+'px)'; e.preventDefault(); } function move(e){ if(!dragging)return; var pt=e.touches?e.touches[0]:e; card.style.transform='translate('+(ox+pt.clientX-sx)+'px,'+(oy+pt.clientY-sy)+'px)'; } function up(){ dragging=false; } handle.addEventListener('mousedown',down); document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); handle.addEventListener('touchstart',down,{passive:false}); document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up); } }; window.closeFlagModal = function(){ var o=document.getElementById('flagModalOverlay'); if(o) o.style.display='none'; }; window.uploadFlagImage = function(input){ if(!input.files||!input.files[0]) return; var fd=new FormData(); fd.append('image', input.files[0]); fd.append('for_flag','1'); fetch('/api/upload-image/', {method:'POST', credentials:'same-origin', headers:{'X-CSRFToken':_flagCsrf()}, body:fd}) .then(function(r){return r.json();}).then(function(d){ if(d.url){ var cap = (prompt('Briefly describe this image (what it shows, and the source if any):','') || 'diagram').trim(); cap = cap.replace(/[\[\]]/g,''); var md = '\n![' + cap + '](' + d.url + ')\n'; if(window._flagMde){ window._flagMde.codemirror.replaceSelection(md); } else { var ta=document.getElementById('flagDesc'); if(ta){ ta.value += md; } } } else { alert(d.error||'Image upload failed.'); } input.value=''; }).catch(function(){ alert('Image upload failed.'); input.value=''; }); }; window.uploadFlagAttachment = function(input){ if(!input.files||!input.files[0]) return; var nm=document.getElementById('flagAttachName'); nm.textContent='Uploading…'; var fd=new FormData(); fd.append('file', input.files[0]); fetch('/flag/upload-attachment/', {method:'POST', credentials:'same-origin', headers:{'X-CSRFToken':_flagCsrf()}, body:fd}) .then(function(r){return r.json();}).then(function(d){ if(d.url){ window._flagAttach.push({url:d.url,name:d.name}); window._drawFlagAttach(); input.value=''; } else { nm.textContent=''; alert(d.error||'File upload failed.'); } }).catch(function(e){ nm.textContent=''; console.error('flag attach failed:', e); alert('File upload failed: ' + (e && e.message ? e.message : e)); }); }; window._drawFlagAttach = function(){ var box=document.getElementById('flagAttachName'); if(!box) return; var a=window._flagAttach||[]; if(!a.length){ box.innerHTML=''; return; } box.innerHTML = a.map(function(f,i){ return '<span style="display:inline-flex;align-items:center;gap:6px;background:#EEF7F0;' + 'border:1px solid #A9E3C4;border-radius:999px;padding:4px 6px 4px 11px;margin:3px 6px 3px 0;' + 'font-size:12.5px;color:#1A5C36;font-weight:600;">📎 ' + f.name + '<button type="button" onclick="_rmFlagAttach(' + i + ')" title="Remove" ' + 'style="background:none;border:none;color:#1A5C36;font-size:15px;line-height:1;' + 'cursor:pointer;padding:0 3px;">×</button></span>'; }).join(''); }; window._rmFlagAttach = function(i){ window._flagAttach.splice(i,1); window._drawFlagAttach(); }; window.submitFlag = function(){ var desc = (window._flagMde ? window._flagMde.value() : (document.getElementById('flagDesc').value||'')).trim(); var atts = window._flagAttach||[]; if(!desc && !atts.length){ alert('Please describe your suggestion or attach a file.'); return; } var btn=document.getElementById('flagSubmitBtn'); btn.disabled=true; btn.textContent='Submitting…'; fetch(window._flagTarget || ('/chapter/' + (typeof CHAPTER_ID !== 'undefined' ? CHAPTER_ID : '') + '/flag/'), { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRFToken':_flagCsrf()}, body: JSON.stringify({location:(document.getElementById('flagLoc')||{}).textContent||'', description:desc, safety_critical:false, topic_id: window._stcFlagTopicId, attachments:atts}) }).then(function(r){ return r.json(); }).then(function(d){ if(d.ok){ var o=document.getElementById('flagModalOverlay'); o.innerHTML='<div style="background:#fff;border-radius:16px;max-width:420px;padding:28px;text-align:center;"><div style="font-size:32px;">✅</div><div style="font-size:16px;font-weight:700;color:var(--primary);margin:10px 0 6px;">Flag submitted</div><div style="font-size:13.5px;color:#6B7B8D;line-height:1.5;">Thank you — our team will review it. If accepted, you\'ll earn credits you can spend on downloads and customisation requests.</div><button onclick="closeFlagModal()" style="margin-top:18px;padding:10px 22px;border-radius:9px;border:none;background:var(--primary);color:#fff;font:inherit;font-weight:700;cursor:pointer;">Done</button></div>'; } else { alert(d.error||'Could not submit.'); btn.disabled=false; btn.textContent='Submit flag'; } }).catch(function(){ alert('Network error.'); btn.disabled=false; btn.textContent='Submit flag'; }); }; const CSRF = 'G2O69bEjXMU91nvhN18aPlypwWHFeXctj0t90BzxqK4ThJg3DuTzbHYvmr8PJYxm'; let editMode = false; let currentSectionKey = null; function toggleEditMode() { editMode = !editMode; const btn = document.getElementById('editModeBtn'); const panel = document.getElementById('editPanel'); const addBtn = document.getElementById('addTopicBtn'); document.querySelectorAll('.edit-controls').forEach(el => el.style.display = editMode ? 'flex' : 'none'); document.body.classList.toggle('edit-mode-on', editMode); if (editMode) { btn.textContent = '✅ Done'; btn.style.background='#E8F5E9'; btn.style.color='#1E8449'; btn.style.borderColor='#1E8449'; panel.style.display = 'block'; addBtn.style.display = 'block'; loadAll(); } else { btn.textContent = '✏️ Edit'; btn.style.background='white'; btn.style.color='#6B7B8D'; btn.style.borderColor='#CBD5E0'; panel.style.display = 'none'; addBtn.style.display = 'none'; document.body.classList.remove('note-editing'); } } async function api(url, method='GET', body=null) { const opts = {method, headers:{'X-CSRFToken':CSRF,'Content-Type':'application/json'}}; if (body) opts.body = JSON.stringify(body); const r = await fetch(url, opts); return r.json(); } function toast(msg, ok=true) { const t = document.createElement('div'); t.textContent = msg; t.style.cssText = `position:fixed;bottom:24px;right:24px;padding:10px 18px;border-radius:8px;background:${ok?'#1E8449':'#DC2626'};color:white;font-size:13px;font-weight:600;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.15);`; document.body.appendChild(t); setTimeout(()=>t.remove(),3000); } function openModal(title, bodyHtml, saveFn) { document.getElementById('modalTitle').textContent = title; document.getElementById('modalBody').innerHTML = bodyHtml; document.getElementById('modalSaveBtn').onclick = saveFn; document.getElementById('editModal').style.display = 'flex'; } function closeModal() { document.getElementById('editModal').style.display = 'none'; } function inp(id, label, val='', type='text') { return `<div style="margin-bottom:12px;"><label style="font-size:12px;color:#6B7B8D;font-weight:600;display:block;margin-bottom:4px;">${label}</label><input id="${id}" type="${type}" value="${val}" style="width:100%;padding:8px 10px;border:1px solid #CBD5E0;border-radius:6px;font-size:13px;"></div>`; } function ta(id, label, val='', rows=3) { return `<div style="margin-bottom:12px;"><label style="font-size:12px;color:#6B7B8D;font-weight:600;display:block;margin-bottom:4px;">${label}</label><textarea id="${id}" rows="${rows}" style="width:100%;padding:8px 10px;border:1px solid #CBD5E0;border-radius:6px;font-size:13px;resize:vertical;">${val}</textarea></div>`; } function sel(id, label, options, selected='') { const opts = options.map(o=>Array.isArray(o)?`<option value="${o[0]}" ${selected===o[0]?'selected':''}>${o[1]}</option>`:`<option ${selected===o?'selected':''}>${o}</option>`).join(''); return `<div style="margin-bottom:12px;"><label style="font-size:12px;color:#6B7B8D;font-weight:600;display:block;margin-bottom:4px;">${label}</label><select id="${id}" style="width:100%;padding:8px 10px;border:1px solid #CBD5E0;border-radius:6px;font-size:13px;">${opts}</select></div>`; } function g(id) { const el=document.getElementById(id); return el?el.value:''; } // Self-Assessment and Exam Questions are edited via their own per-heading Edit // buttons (blob-backed, the source the page actually renders). The row-based // loaders below are retained but no longer mounted in this panel. function loadAll() { loadPracticeQuestions(); } function card(content, borderColor='#CBD5E0', bg='white') { return `<div style="border:1px solid ${borderColor};border-radius:6px;padding:10px 12px;margin-bottom:8px;background:${bg};">${content}</div>`; } function editDelBtns(editFn, delFn) { return `<div style="display:flex;gap:4px;flex-shrink:0;"><button onclick="${editFn}" style="padding:4px 8px;font-size:11px;border:1px solid #CBD5E0;border-radius:4px;cursor:pointer;background:white;">✏️</button><button onclick="${delFn}" style="padding:4px 8px;font-size:11px;border:1px solid #FCA5A5;border-radius:4px;cursor:pointer;background:#FEF2F2;color:#DC2626;">🗑</button></div>`; } // ── Practice Questions ── async function loadPracticeQuestions() { const el = document.getElementById('practiceQsList'); if (!el) return; el.innerHTML = '<p style="color:#888;font-size:12px;padding:8px;">Loading...</p>'; const r = await api(`/api/chapter/${CHAPTER_ID}/practice-questions/`); // Practice questions are TOPIC-keyed. When a specific topic is open in the editor, // show only that topic's questions -- otherwise the list is chapter-wide and does // not match the note above it. let items = r.items || []; let scopeNote = ''; if (currentNoteType === 'topic' && currentTopicId) { const tid = String(currentTopicId); const scoped = items.filter(q => String(q.topic_id) === tid); const _sel = document.getElementById('topicSelector'); const _lbl = (_sel && _sel.selectedOptions && _sel.selectedOptions[0]) ? _sel.selectedOptions[0].textContent.trim().split(' ')[0] : ''; scopeNote = `<p style="margin:0 0 8px;font-size:11px;color:var(--accent);">Showing questions for topic ${_lbl} only. Adding a question attaches it to this topic.</p>`; items = scoped; } else if (currentNoteType === 'section') { el.innerHTML = '<p style="color:#888;font-size:12px;text-align:center;padding:16px;">Practice questions belong to topics. Open a topic to edit them.</p>'; return; } if (!items.length) { el.innerHTML = scopeNote + '<p style="color:#888;font-size:12px;text-align:center;padding:16px;">No questions yet.</p>'; return; } el.innerHTML = scopeNote + items.map(q=>card(` <div style="display:flex;justify-content:space-between;gap:8px;"> <div style="flex:1;"><span style="font-size:10px;background:#FFF8E1;padding:1px 7px;border-radius:8px;color:var(--accent);font-weight:600;">${q.question_type} · ${q.marks}m</span> <p style="margin:5px 0 0;font-size:13px;">${q.question_text?.substring(0,120)||''}${(q.question_text?.length||0)>120?'...':''}</p></div> ${editDelBtns(`editPracticeQuestion(${q.id})`,`deletePracticeQuestion(${q.id})`)} </div>`,'#E0C88A','#FFFBF0')).join(''); } function pqForm(q={}) { return sel('mQType','Type',[['short_answer','Short Answer'],['mcq','MCQ'],['structured','Structured'],['scenario','Scenario']],q.question_type||'short_answer') + ta('mQText','Question',q.question_text||'',3) + inp('mQMarks','Marks',q.marks||2,'number') + ta('mQAnswer','Model Answer (optional)',q.correct_answer||'',2); } function addPracticeQuestion() { if (!(currentNoteType === 'topic' && currentTopicId)) { toast('Open a topic first — a question must belong to a topic.', false); return; } openModal('Add Practice Question', pqForm(), async ()=>{ await api(`/api/chapter/${CHAPTER_ID}/practice-questions/`,'POST',{question_type:g('mQType'),question_text:g('mQText'),marks:+g('mQMarks'),correct_answer:g('mQAnswer')}); toast('✅ Question added'); closeModal(); loadPracticeQuestions(); }); } async function editPracticeQuestion(id) { const q = await api(`/api/practice-question/${id}/`); openModal('Edit Practice Question', pqForm(q), async ()=>{ await api(`/api/practice-question/${id}/`,'PUT',{question_type:g('mQType'),question_text:g('mQText'),marks:+g('mQMarks'),correct_answer:g('mQAnswer')}); toast('✅ Saved'); closeModal(); loadPracticeQuestions(); }); } async function deletePracticeQuestion(id) { if(!confirm('Delete this question?'))return; await api(`/api/practice-question/${id}/`,'DELETE'); toast('🗑 Deleted'); loadPracticeQuestions(); } // ── Self Assessment ── async function loadSelfAssessment() { const el = document.getElementById('selfAssessmentList'); el.innerHTML = '<p style="color:#888;font-size:12px;padding:8px;">Loading...</p>'; const r = await api(`/api/chapter/${CHAPTER_ID}/self-assessment/`); if (!r.items?.length) { el.innerHTML='<p style="color:#888;font-size:12px;text-align:center;padding:16px;">No items yet.</p>'; return; } const colors={mcq:'#EBF5FB',short_answer:'#F0FFF4',oral:'#FFF8E1',case_study:'#FFF0F0'}; el.innerHTML = r.items.map(i=>card(` <div style="display:flex;justify-content:space-between;gap:8px;"> <div style="flex:1;"><span style="font-size:10px;background:var(--primary);color:white;padding:1px 7px;border-radius:8px;font-weight:600;">${(i.item_type||'').toUpperCase().replace('_',' ')}</span> <p style="margin:5px 0 0;font-size:13px;">${(i.question_text||'').substring(0,120)}${(i.question_text?.length||0)>120?'...':''}</p></div> ${editDelBtns(`editSelfAssessment(${i.id})`,`deleteSelfAssessment(${i.id})`)} </div>`,`#2E86C1`,colors[i.item_type]||'white')).join(''); } function saForm(i={}) { return sel('mSAType','Type',[['mcq','MCQ'],['short_answer','Short Answer'],['oral','Oral'],['case_study','Case Study']],i.item_type||'mcq') + ta('mSAText','Question',i.question_text||'',4) + ta('mSAAnswer','Answer/Guide',i.correct_answer||'',2) + inp('mSAMarks','Marks',i.marks||1,'number'); } function addSelfAssessment() { openModal('Add Self-Assessment Item', saForm(), async ()=>{ await api(`/api/chapter/${CHAPTER_ID}/self-assessment/`,'POST',{item_type:g('mSAType'),question_text:g('mSAText'),correct_answer:g('mSAAnswer'),marks:+g('mSAMarks')}); toast('✅ Added'); closeModal(); loadSelfAssessment(); }); } async function editSelfAssessment(id) { const i = await api(`/api/self-assessment/${id}/`); openModal('Edit Self-Assessment', saForm(i), async ()=>{ await api(`/api/self-assessment/${id}/`,'PUT',{item_type:g('mSAType'),question_text:g('mSAText'),correct_answer:g('mSAAnswer'),marks:+g('mSAMarks')}); toast('✅ Saved'); closeModal(); loadSelfAssessment(); }); } async function deleteSelfAssessment(id) { if(!confirm('Delete?'))return; await api(`/api/self-assessment/${id}/`,'DELETE'); toast('🗑 Deleted'); loadSelfAssessment(); } // ── Exam Questions ── async function loadExamQuestions() { const el = document.getElementById('examQuestionsList'); el.innerHTML = '<p style="color:#888;font-size:12px;padding:8px;">Loading...</p>'; const r = await api(`/api/chapter/${CHAPTER_ID}/exam-questions/`); if (!r.items?.length) { el.innerHTML='<p style="color:#888;font-size:12px;text-align:center;padding:16px;">No questions yet.</p>'; return; } const secA = r.items.filter(q=>q.section==='A'); const secB = r.items.filter(q=>q.section==='B'); el.innerHTML = `<p style="font-size:11px;font-weight:700;color:var(--primary);margin:0 0 6px;">SECTION A (${secA.length})</p>` + secA.map(q=>card(`<div style="display:flex;justify-content:space-between;gap:8px;"><div style="flex:1;"><span style="font-size:10px;color:#888;">Q${q.order} · ${q.marks}m</span><p style="margin:4px 0 0;font-size:13px;">${(q.question_text||'').substring(0,100)}...</p></div>${editDelBtns(`editExamQuestion(${q.id})`,`deleteExamQuestion(${q.id})`)}</div>`,'#C5D0DE')).join('') + `<p style="font-size:11px;font-weight:700;color:var(--primary);margin:10px 0 6px;">SECTION B (${secB.length})</p>` + secB.map(q=>card(`<div style="display:flex;justify-content:space-between;gap:8px;"><div style="flex:1;"><span style="font-size:10px;color:#888;">Q${q.order}${q.is_compulsory?' · COMPULSORY':''} · ${q.marks}m</span><p style="margin:4px 0 0;font-size:13px;">${(q.question_text||'').substring(0,100)}...</p></div>${editDelBtns(`editExamQuestion(${q.id})`,`deleteExamQuestion(${q.id})`)}</div>`,'#C5D0DE',q.is_compulsory?'#F0F4F8':'white')).join(''); } function eqForm(q={}, section='A') { return sel('mEQSection','Section',[['A','Section A'],['B','Section B']],q.section||section) + ta('mEQText','Question',q.question_text||'',4) + inp('mEQMarks','Marks',q.marks||(section==='A'?4:20),'number') + ta('mEQAnswer','Model Answer',q.model_answer||'',3) + `<div style="margin-bottom:12px;"><label style="font-size:13px;"><input type="checkbox" id="mEQComp" ${q.is_compulsory?'checked':''}> Compulsory (Q11)</label></div>`; } function addExamQuestion(section) { openModal(`Add Section ${section} Question`, eqForm({},section), async ()=>{ await api(`/api/chapter/${CHAPTER_ID}/exam-questions/`,'POST',{section:g('mEQSection'),question_text:g('mEQText'),marks:+g('mEQMarks'),model_answer:g('mEQAnswer'),is_compulsory:document.getElementById('mEQComp').checked}); toast('✅ Added'); closeModal(); loadExamQuestions(); }); } async function editExamQuestion(id) { const q = await api(`/api/exam-question/${id}/`); openModal('Edit Exam Question', eqForm(q), async ()=>{ await api(`/api/exam-question/${id}/`,'PUT',{section:g('mEQSection'),question_text:g('mEQText'),marks:+g('mEQMarks'),model_answer:g('mEQAnswer'),is_compulsory:document.getElementById('mEQComp').checked}); toast('✅ Saved'); closeModal(); loadExamQuestions(); }); } async function deleteExamQuestion(id) { if(!confirm('Delete?'))return; await api(`/api/exam-question/${id}/`,'DELETE'); toast('🗑 Deleted'); loadExamQuestions(); } // ── Practicals ── async function loadPracticals() { const el = document.getElementById('practicalsList'); if (!el) return; el.innerHTML = '<p style="color:#888;font-size:12px;padding:8px;">Loading...</p>'; const r = await api(`/api/chapter/${CHAPTER_ID}/practicals/`); if (!r.items?.length) { el.innerHTML='<p style="color:#888;font-size:12px;text-align:center;padding:16px;">No practicals yet.</p>'; return; } el.innerHTML = r.items.map(p=>card(` <div style="display:flex;justify-content:space-between;gap:8px;"> <div style="flex:1;"><span style="font-size:10px;background:#E8F5E9;color:#1B5E20;padding:1px 7px;border-radius:8px;font-weight:600;">P${p.order} · ${p.time_minutes||'?'}min · ${p.activity_type||''}</span> <p style="margin:5px 0 2px;font-size:13px;font-weight:600;color:#1B5E20;">${p.title||''}</p> <p style="margin:0;font-size:12px;color:#6B7B8D;">${(p.scenario||'').substring(0,80)}...</p></div> ${editDelBtns(`editPractical(${p.id})`,`deletePractical(${p.id})`)} </div>`,'#A5D6A7')).join(''); } function pForm(p={}) { return inp('mPTitle','Title',p.title||'') + `<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">` + inp('mPTime','Time (minutes)',p.time_minutes||60,'number') + sel('mPType','Type',['Individual','Pairs','Group of 3','Group of 4'],p.activity_type||'Individual') + `</div>` + ta('mPResources','Resources Required',p.resources||'',3) + ta('mPScenario','Kenyan Scenario',p.scenario||'',3) + ta('mPTasks','Tasks (i. ii. iii.)',p.tasks||'',4) + ta('mPCriteria','Assessor Criteria',p.assessor_criteria||'',3); } function addPractical() { openModal('Add Practical Activity', pForm(), async ()=>{ await api(`/api/chapter/${CHAPTER_ID}/practicals/`,'POST',{title:g('mPTitle'),time_minutes:+g('mPTime'),activity_type:g('mPType'),resources:g('mPResources'),scenario:g('mPScenario'),tasks:g('mPTasks'),assessor_criteria:g('mPCriteria')}); toast('✅ Practical added'); closeModal(); loadPracticals(); }); } async function editPractical(id) { const p = await api(`/api/practical/${id}/`); openModal('Edit Practical', pForm(p), async ()=>{ await api(`/api/practical/${id}/`,'PUT',{title:g('mPTitle'),time_minutes:+g('mPTime'),activity_type:g('mPType'),resources:g('mPResources'),scenario:g('mPScenario'),tasks:g('mPTasks'),assessor_criteria:g('mPCriteria')}); toast('✅ Saved'); closeModal(); loadPracticals(); }); } async function deletePractical(id) { if(!confirm('Delete this practical?'))return; await api(`/api/practical/${id}/`,'DELETE'); toast('🗑 Deleted'); loadPracticals(); } // ── Notes Editor ── let mde = null; let currentTopicId = null; let currentNoteType = null; async function loadTopicNote(value) { const wrap = document.getElementById('notesEditorWrap'); const placeholder = document.getElementById('notesEditorPlaceholder'); const status = document.getElementById('notesSaveStatus'); if (!value) { wrap.style.display='none'; placeholder.style.display='block'; document.body.classList.remove('note-editing'); return; } placeholder.style.display = 'none'; wrap.style.display = 'block'; // Image "Remove" buttons belong to NOTE editing only -- NOT to the sidebar's // topic-name edit mode. Turn them on only now that a note is really open. document.body.classList.add('note-editing'); status.textContent = '⏳ Loading...'; status.style.color = '#888'; // Destroy existing editor if (mde) { mde.toTextArea(); mde = null; } let mdContent = ''; if (value === 'full') { currentNoteType = 'full'; currentTopicId = null; const r = await api(`/api/chapter/${CHAPTER_ID}/notes/`); mdContent = r.content_md || ''; } else { currentNoteType = 'topic'; currentTopicId = value; currentSectionKey = null; const r = await api(`/api/topic/${value}/note/`); mdContent = r.content_md || ''; if (typeof loadPracticeQuestions === 'function') loadPracticeQuestions(); } // Init EasyMDE — recreate textarea each time to avoid stale element const wrap2 = document.getElementById("notesEditorWrap"); wrap2.querySelector("textarea")?.remove(); const freshTA = document.createElement("textarea"); freshTA.id = "notesEditor"; freshTA.value = mdContent; wrap2.insertBefore(freshTA, wrap2.firstChild); mde = new EasyMDE({ element: freshTA, spellChecker: false, autosave: { enabled: false }, previewRender: window.mdeMathPreview, toolbar: [ 'bold','italic','heading','|', 'quote','unordered-list','ordered-list','|', 'link','image','table','|', { name: 'upload-image', action: function(editor) { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/jpeg,image/png,image/gif,image/webp,image/svg+xml'; input.onchange = async function() { const file = input.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); toast('⏳ Uploading image...'); const resp = await fetch('/api/upload-image/', { method: 'POST', headers: {'X-CSRFToken': CSRF}, body: formData, }); const data = await resp.json(); if (data.url) { const cm = editor.codemirror; const cursor = cm.getCursor(); const caption = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '); cm.replaceRange(`\n![${caption}](${data.url})\n*Figure: ${caption}. Source: uploaded*\n`, cursor); toast('✅ Image inserted'); } else { toast('❌ Upload failed: ' + (data.error||'unknown'), false); } }; input.click(); }, className: 'fa fa-upload', title: 'Upload Image', }, '|', 'preview','side-by-side','fullscreen','|', 'guide' ], minHeight: '500px', placeholder: 'Write markdown here...', }); status.textContent = `✅ ${mdContent.split(' ').filter(w=>w).length} words`; status.style.color = '#1E8449'; } function togglePreviewBtn(btn) { if (!mde) { toast('Select a topic first', false); return; } EasyMDE.togglePreview(mde); var on = mde.isPreviewActive(); btn.style.background = on ? 'var(--primary)' : '#fff'; btn.style.color = on ? '#fff' : 'var(--primary)'; btn.innerHTML = on ? '\u270E Back to editing' : '\u{1F441} Preview'; } async function saveNotes() { if (!mde) { toast('Select a topic first', false); return; } const status = document.getElementById('notesSaveStatus'); const content_md = mde.value(); status.textContent = 'Saving...'; status.style.color = '#888'; let _resp; if (currentNoteType === 'full') { _resp = await api(`/api/chapter/${CHAPTER_ID}/notes/`, 'PUT', {content_md}); } else if (currentNoteType === 'section') { _resp = await api(`/api/chapter/${CHAPTER_ID}/section/${currentSectionKey}/`, 'PUT', {content_md}); } else { _resp = await api(`/api/topic/${currentTopicId}/note/`, 'PUT', {content_md}); } if (_resp && _resp.error) { status.textContent = '⚠ ' + _resp.error; status.style.color = '#DC2626'; // A formatting refusal names exactly what is wrong — show it where the editor is, // not in a toast that vanishes before it can be acted on. Nothing is lost: the // text stays in the editor to be fixed. if (_resp.format_errors && _resp.format_errors.length) { showFormatIssues(_resp.format_errors, _resp.format_warnings || [], true); } else { toast(_resp.error, false); } return; } clearFormatIssues(); status.textContent = '✅ Saved'; status.style.color = '#1E8449'; // saved, but say plainly if it removed a lot — better found now than by a student if (_resp && _resp.format_warnings && _resp.format_warnings.length) { showFormatIssues([], _resp.format_warnings, false); toast('✅ Saved — check the note below the editor'); if (typeof closeFloatingEditor === 'function') closeFloatingEditor(); setTimeout(() => location.reload(), 4200); return; } toast('✅ Notes saved — reloading...'); if (typeof closeFloatingEditor === 'function') closeFloatingEditor(); setTimeout(() => location.reload(), 1500); } function clearFormatIssues() { const b = document.getElementById('fmtIssues'); if (b) { b.style.display = 'none'; b.innerHTML = ''; } } function showFormatIssues(errors, warnings, blocking) { let b = document.getElementById('fmtIssues'); if (!b) { b = document.createElement('div'); b.id = 'fmtIssues'; const wrap = document.getElementById('notesEditorWrap'); if (wrap && wrap.parentNode) wrap.parentNode.insertBefore(b, wrap.nextSibling); else return; } let html = ''; if (errors.length) { html += '<div class="fmt-head fmt-bad">Not saved — this would break the formatting</div>' + '<ul class="fmt-list">' + errors.map(function(e){ return '<li>' + e.replace(/</g,'&lt;') + '</li>'; }).join('') + '</ul>' + '<div class="fmt-hint">Your text is still here. Fix the point above, then use ' + '<strong>Preview</strong> to check it reads right before saving.</div>'; } if (warnings.length) { html += '<div class="fmt-head fmt-warn">' + (blocking ? 'Also worth checking' : 'Saved — but worth checking') + '</div>' + '<ul class="fmt-list">' + warnings.map(function(w){ return '<li>' + w.replace(/</g,'&lt;') + '</li>'; }).join('') + '</ul>'; } b.innerHTML = html; b.className = errors.length ? 'fmt-box fmt-box-bad' : 'fmt-box fmt-box-warn'; b.style.display = 'block'; b.scrollIntoView({block:'nearest', behavior:'smooth'}); } // ── Topics ── function editTopic(id, number, title) { openModal('Edit Topic', inp('mTNum','Number',number)+inp('mTTitle','Title',title), async ()=>{ const r = await api(`/api/topic/${id}/`,'PUT',{number:g('mTNum'),title:g('mTTitle')}); if(r.success){document.querySelector(`#sidebar-topic-${id} .topic-title-display`).textContent=r.title; toast('✅ Saved'); closeModal();} }); } async function deleteTopic(id) { if(!confirm('Delete this topic?'))return; await api(`/api/topic/${id}/`,'DELETE'); document.getElementById(`sidebar-topic-${id}`)?.remove(); toast('🗑 Deleted'); } function addTopic() { openModal('Add Topic', inp('mTNum','Number (e.g. 1.15)','')+inp('mTTitle','Title','')+`<div><label style="font-size:13px;"><input type="checkbox" id="mTSub"> Subtopic</label></div>`, async ()=>{ await api(`/api/chapter/${CHAPTER_ID}/topics/`,'POST',{number:g('mTNum'),title:g('mTTitle'),is_subtopic:document.getElementById('mTSub').checked}); toast('✅ Topic added'); closeModal(); setTimeout(()=>location.reload(),1000); }); } // ── Structure editor (7 Oct 2026): the whole topic tree, edited in place ── const stEsc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); let ST = null; document.head.insertAdjacentHTML('beforeend', `<style> #structTree{padding:4px 0 10px} .st-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 12px;font-size:12px;color:#6B7B8D} .st-head b{color:var(--primary)} .st-last{margin:4px 10px 8px;padding:6px 8px;background:#FFF8E1;border:1px solid #F3D57A;border-radius:6px;font-size:11.5px;color:#5D4A00} .st-last button{margin-left:6px;font-size:11px;padding:1px 8px;border:1px solid #C9A227;border-radius:10px;background:#fff;cursor:pointer} .st-row{display:flex;align-items:flex-start;gap:6px;padding:5px 8px;border-bottom:1px solid #EEF1F4;font-size:12.5px} .st-row:hover{background:#F6F8FB} .st-num{min-width:44px;font-weight:600;color:var(--primary);font-size:11.5px;padding-top:1px} .st-title{flex:1;line-height:1.35} .st-sub .st-title{color:#4A5568} .st-only .st-num,.st-only .st-title{color:#8A6D00} .st-tag{display:inline-block;font-size:10px;background:#FFF3C4;color:#7A5B00;border-radius:8px;padding:0 6px;margin-left:4px} .st-row{flex-wrap:wrap}.st-title{flex:1 1 150px;min-width:0} .st-btns{display:flex;gap:2px;flex-shrink:0;margin-left:auto} .st-cur{margin:4px 10px 8px;padding:8px 10px;background:#EEF4FB;border:1px solid #C9DBEF;border-radius:6px;font-size:12px;color:#1F3A5F;line-height:1.45} .st-cur a{display:inline-block;margin-top:4px;font-weight:600} .st-sum{margin-top:6px} .st-miss{background:#FEF2F2}.st-miss .st-num,.st-miss .st-title{color:#B42318} .st-mini{font-size:10.5px;padding:0 7px;margin-left:4px;border:1px solid #98A2B3;border-radius:9px;background:#fff;cursor:pointer;color:#344054} .st-t-miss{background:#FEE4E2;color:#B42318}.st-t-nosec{background:#FFE8D9;color:#9A3412}.st-t-off{background:#EEF0F3;color:#475467}.st-t-ok{background:#DCFCE7;color:#166534} .st-btns button{font-size:11px;padding:1px 5px;border:1px solid #CBD5E0;border-radius:3px;background:#fff;cursor:pointer;line-height:1.3} .st-btns .st-del{border-color:#FCA5A5;background:#FEF2F2;color:#DC2626} .st-addend{display:block;width:calc(100% - 20px);margin:8px 10px 0;padding:6px;border:1px dashed #CBD5E0;border-radius:6px;background:#fff;cursor:pointer;font-size:12px;color:#6B7B8D} .st-opt{display:block;margin:6px 0;font-size:13px} </style>`); // ── Compare side by side (13 Oct 2026): the curriculum pages beside the topic list, as on the review screen. // Laptop: list | pages (notes hidden until you go back). Wide screen: list | pages | notes. Remembered per browser. document.head.insertAdjacentHTML('beforeend', `<style> body.st-compare .two-col, body.st-compare .two-col:has(.sidebar.topics-open) { display: grid !important; grid-template-columns: var(--st-left, 50%) minmax(0, 1fr) !important; gap: 12px; align-items: start; } body.st-compare .two-col > .sidebar { width: auto !important; max-width: none !important; min-width: 0 !important; } #stPages { position: sticky; } #stDrag { position: absolute; left: -10px; top: 0; bottom: 0; width: 10px; cursor: col-resize; z-index: 3; } #stDrag::after { content: ""; position: absolute; left: 3px; top: 50%; height: 48px; margin-top: -24px; width: 4px; border-radius: 2px; background: #C9D3E0; } #stDrag:hover::after { background: #4A7FB5; } /* compare mode uses the whole screen: the site's left menu and the outer margins go, the help paragraph goes */ body.st-compare .dash-sidebar, body.st-compare .dash-nav, body.st-compare .dash-shell > aside { display: none !important; } body.st-compare .dash-main { margin-left: 0 !important; width: 100% !important; max-width: none !important; } body.st-compare .dash-inner, body.st-compare .page-wrapper { max-width: none !important; padding-left: 10px !important; padding-right: 10px !important; } body.st-compare .st-help { display: none; } body.st-compare .two-col > :not(.sidebar):not(#stPages) { display: none; } #stPages { position: sticky; align-self: start; border: 1px solid #E5E8EE; border-radius: 10px; overflow: hidden; background: #fff; } #stPages iframe { width: 100%; height: 100%; border: 0; display: block; } #stPages .st-pages-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; font-size: 12px; background: #EEF4FB; border-bottom: 1px solid #C9DBEF; color: #1F3A5F; } @media (min-width: 1700px) { body.st-compare .two-col, body.st-compare .two-col:has(.sidebar.topics-open) { grid-template-columns: var(--st-left, 34%) minmax(0, 1fr) minmax(0, 1.2fr) !important; } body.st-compare .two-col > :not(.sidebar):not(#stPages) { display: block; } } </style>`); function stPagesSize() { const sb = document.querySelector('.two-col > .sidebar'), pg = document.getElementById('stPages'); if (!sb || !pg) return; const top = parseInt(sb.style.top || '12', 10) || 12; pg.style.top = top + 'px'; pg.style.height = 'calc(100vh - ' + (top + 24) + 'px)'; } function stCompare(pdf, on) { const want = on === undefined ? !document.body.classList.contains('st-compare') : on; const col = document.querySelector('.two-col'), sb = col && col.querySelector('.sidebar'); let pg = document.getElementById('stPages'); if (want && pdf && col) { if (sb && !sb.classList.contains('topics-open')) sb.classList.add('topics-open'); if (!pg) { pg = document.createElement('div'); pg.id = 'stPages'; pg.innerHTML = `<div class="st-pages-bar"><b>Curriculum pages</b><span><a href="${pdf}" target="_blank" rel="noopener">open in a tab &#8599;</a> &nbsp; <a href="#" onclick="stCompare(null,false);return false;">close</a></span></div><iframe src="${pdf}#toolbar=0&navpanes=0&view=FitH" title="Curriculum pages"></iframe>`; sb.after(pg); // drag the divider to share the width between the list and the pages; remembered per browser const dg = document.createElement('div'); dg.id = 'stDrag'; dg.title = 'Drag to resize'; pg.appendChild(dg); dg.addEventListener('mousedown', e => { e.preventDefault(); const fr = pg.querySelector('iframe'); if (fr) fr.style.pointerEvents = 'none'; const mv = ev => { const r = col.getBoundingClientRect(); const pct = Math.max(25, Math.min(75, (ev.clientX - r.left) / r.width * 100)); col.style.setProperty('--st-left', pct.toFixed(1) + '%'); }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (fr) fr.style.pointerEvents = ''; try { localStorage.setItem('pp_compare_left', col.style.getPropertyValue('--st-left')); } catch (e) {} }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); try { const w = localStorage.getItem('pp_compare_left'); if (w) col.style.setProperty('--st-left', w); } catch (e) {} } document.body.classList.add('st-compare'); stPagesSize(); } else { if (pg) pg.remove(); document.body.classList.remove('st-compare'); } try { localStorage.setItem('pp_compare', document.body.classList.contains('st-compare') ? '1' : '0'); } catch (e) {} const btns = document.querySelectorAll('#structTree .st-cur button.st-mini'); btns.forEach(b => { if (/Compare side by side|Back to the notes/.test(b.textContent)) b.textContent = document.body.classList.contains('st-compare') ? 'Back to the notes' : 'Compare side by side'; }); } function stCompareRestore(pdf) { let on = false; try { on = localStorage.getItem('pp_compare') === '1'; } catch (e) {} if (on && pdf && !document.getElementById('stPages')) stCompare(pdf, true); } window.addEventListener('resize', stPagesSize); // Extras (sub-topics the notes have that the curriculum does not list) hidden by default, so the list reads like // the curriculum; one button shows them. Chapter title edited in place. 13 Oct 2026 document.head.insertAdjacentHTML('beforeend', `<style> body.st-hide-extra #structTree .st-only { display: none; } .st-ctitle { outline: none; border-radius: 3px; padding: 0 3px; cursor: text; } .st-ctitle:hover { background: #F3F5F9; } .st-ctitle:focus { background: #FFF7D6; } </style>`); function stHideExtra() { try { return localStorage.getItem('pp_hide_extra') !== '0'; } catch (e) { return true; } } function stApplyExtra() { document.body.classList.toggle('st-hide-extra', stHideExtra()); } function stToggleExtra() { try { localStorage.setItem('pp_hide_extra', stHideExtra() ? '0' : '1'); } catch (e) {} stApplyExtra(); structLoad(); } document.addEventListener('keydown', e => { const el = e.target.closest && e.target.closest('.st-ctitle'); if (!el) return; if (e.key === 'Escape') { el.textContent = ST.chapter_title; el.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const t = el.textContent.replace(/\s+/g, ' ').trim(); if (!t || t === ST.chapter_title) { el.textContent = ST.chapter_title; el.blur(); return; } structOp({op: 'chapter_title', title: t}).then(() => location.reload()); }); // Clicking anywhere on a topic row selects it: the row is highlighted and its tools stay open under it, as on the // review screen. (13 Oct 2026: tools showed only while the title kept keyboard focus, which the row click took away.) document.addEventListener('mousedown', e => { if (e.target.closest && e.target.closest('.st-tools, .st-btns, .st-mini, .st-new')) return; const row = e.target.closest && e.target.closest('#structTree .st-row[data-id]'); if (!row) return; document.querySelectorAll('#structTree .st-row.st-active').forEach(x => { if (x !== row) x.classList.remove('st-active'); }); row.classList.add('st-active'); }); async function structLoad() { const box = document.getElementById('structTree'); box.innerHTML = '<div class="st-head">Loading the structure…</div>'; const r = await api(`/api/chapter/${CHAPTER_ID}/structure/`); if (!r.ok) { box.innerHTML = `<div class="st-last">${stEsc(r.error || 'Could not load the structure.')}</div>`; return; } ST = r; const inList = r.topics.filter(t => t.id).length, miss = r.topics.filter(t => t.missing || (t.notes_only && t.in_curriculum)).length; const nosec = r.topics.filter(t => t.id && t.in_curriculum && t.has_section === false).length; const off = r.topics.filter(t => t.id && t.in_curriculum === false && !t.covers).length; const nExtra = r.topics.filter(t => t.notes_only && !t.in_curriculum && !t.covers).length; const nPts = r.topics.reduce((n, t) => n + (t.points || []).filter(q => !q.where).length, 0), nWeak = r.topics.reduce((n, t) => n + (t.points || []).filter(q => q.where && q.where.startsWith('the text of')).length, 0); let h = `<div class="st-head"><b>Structure</b><span>${inList} in the topic list</span></div>`; if (r.curriculum) h += `<div class="st-cur"><div><b>Curriculum</b> (${stEsc(r.curriculum.status)}): ${stEsc(r.curriculum.outcome)}</div>` + (r.curriculum.pdf ? `<a href="${r.curriculum.pdf}" target="_blank" rel="noopener">View the curriculum pages &#8599;</a>` + ` <button class="st-mini" style="font-weight:700;margin-left:6px" onclick="stCompare(ST.curriculum.pdf)">${document.body.classList.contains('st-compare') ? 'Back to the notes' : 'Compare side by side'}</button>` : '') + ((r.chapter_title && r.curriculum.outcome && stNorm(r.chapter_title) !== stNorm(r.curriculum.outcome)) ? `<div class="st-sum">Chapter title "<b>${stEsc(r.chapter_title)}</b>" differs from our copy's outcome. Check the content table, then: <button class="st-mini" onclick="structChapterTitle()">chapter is wrong</button><button class="st-mini" onclick="curOutcome()">our copy is wrong</button></div>` : '') + ((r.curriculum.corrected || []).length ? `<div class="st-sum" style="color:#9A3412">The curriculum PDF misnumbers some items here. Shown by their position: ${r.curriculum.corrected.map(stEsc).join('; ')}. Check against the pages.</div>` : '') + `<div class="st-sum">Chapter title: <b class="st-ctitle" contenteditable="true" spellcheck="false" title="Click to edit; Enter saves, Esc cancels">${stEsc(r.chapter_title)}</b></div>` + `<div class="st-sum"><a href="/quality/curriculum/93/outlines/10536/" target="_blank" rel="noopener">Edit the curriculum outline &#8599;</a> <button type="button" class="st-arr" onclick="stArrange()">Arrange as in the outline</button>` + (nExtra ? ` <button class="st-mini" onclick="stToggleExtra()">${stHideExtra() ? 'Show the ' + nExtra + ' extra sub-topic' + (nExtra > 1 ? 's' : '') + ' in the notes' : 'Hide the extras: curriculum only'}</button>` : '') + `</div>` + `<div class="st-sum">${miss ? `<span class="st-tag st-t-miss">${miss} missing from the list</span>` : ''}${nosec ? `<span class="st-tag st-t-nosec">${nosec} without a notes section</span>` : ''}${nPts ? `<span class="st-tag st-t-miss">${nPts} curriculum point${nPts > 1 ? 's' : ''} without notes</span>` : ''}${nWeak ? `<span class="st-tag st-t-weak">${nWeak} point${nWeak > 1 ? 's' : ''} only mentioned in the text: check</span>` : ''}${off ? `<span class="st-tag st-t-off">${off} in the list, not in the curriculum</span>` : ''}${!miss && !nosec && !off && !nPts && !nWeak ? '<span class="st-tag st-t-ok">matches the curriculum</span>' : ''}</div></div>`; else h += `<div class="st-cur">No curriculum outline for this unit yet.</div>`; if (r.last) h += `<div class="st-last">Last change: ${stEsc(r.last.summary)} <span style="opacity:.7">(${stEsc(r.last.who)}, ${stEsc(r.last.when)})</span><button onclick="structUndo()">Undo</button></div>`; const nGlue = r.topics.filter(t => t.id && t.glue).length; if (r.curriculum && r.curriculum.status === 'confirmed' && nGlue) h += `<div class="st-cur" style="background:#FFF7ED;border-color:#FDBA74"><b>${nGlue} title${nGlue > 1 ? 's carry' : ' carries'} leftover text from the curriculum PDF</b> (bullets, column words). <button class="st-mini" style="font-weight:700" onclick="structOp({op:'align'})">Fix ${nGlue > 1 ? 'all ' + nGlue : 'it'}</button></div>`; h += `<div class="st-help">Click a title to edit it. <b>Enter</b> saves; in the middle of a title it splits it, the rest becoming the next topic. Tools under the title: &larr; &rarr; level, + line, + sub, join &uarr;, &times;. Every change is saved at once and can be undone.</div>`; for (const t of r.topics) { const pad = 8 + 14 * Math.max(0, t.depth - 1); if (t.missing || (t.notes_only && t.in_curriculum)) { const ct = t.curriculum_title || t.title; h += `<div class="st-row st-miss" style="padding-left:${pad}px"><span class="st-num">${stEsc(t.number)}</span><span class="st-title">${stEsc(ct)}<span class="st-tag st-t-miss">missing: in the curriculum, not in the chapter</span>${t.has_section ? '' : '<span class="st-tag st-t-nosec">no notes yet</span>'}</span><span class="st-btns">` + (t.implied ? '<span class="st-tag st-t-off">title not captured: read it from the pages, then use +</span></span></div>' : (() => { // the same title already sits in the chapter under another number: renumber that one, never add a twin const twin = r.topics.find(x => x.id && x.in_curriculum === false && stNorm(x.title) === stNorm(ct)); return twin ? `<button onclick='structOp({op:"renumber",topic:${twin.id},number:${JSON.stringify(t.number)}})'>Number ${stEsc(twin.number)} as ${stEsc(t.number)}</button></span></div>` : `<button onclick='structOp({op:"adopt",number:${JSON.stringify(t.number)},title:${JSON.stringify(ct)}})'>Add topic</button></span></div>`; })()); continue; } if (t.notes_only) { h += `<div class="st-row ${t.covers ? 'st-cov' : 'st-only'}" style="padding-left:${pad}px"><span class="st-num">${stEsc(t.number)}</span><span class="st-title">${stEsc(t.title)}${t.covers ? `<span class="st-tag st-t-cov">covers the curriculum point: ${stEsc(t.covers)}</span>` : '<span class="st-tag">extra in the notes</span>' + (() => { const tw = ST.topics.find(x => x.number !== t.number && (x.id || x.notes_only) && stNorm(x.title) === stNorm(t.title)); return (tw ? '<span class="st-tag st-t-miss">repeats ' + stEsc(tw.number) + '</span>' : '') + '<button type="button" class="st-delbtn st-dropbtn" data-num="' + stEsc(t.number) + '" data-title="' + stEsc(t.title) + '">Delete</button>'; })()}</span><span class="st-btns">` + `<button title="Add a topic next to it" onclick="structAdd(null,'${stEsc(t.number)}')">+</button></span></div>`; continue; } let chips = ''; if (t.in_curriculum === false) { const twin = t.numbered && r.topics.find(x => x.missing && !x.implied && stNorm(x.curriculum_title || x.title) === stNorm(t.title)); chips += `<span class="st-tag st-t-off">not in the curriculum</span>` + (twin ? `<button class="st-mini" style="font-weight:700" onclick='structOp({op:"renumber",topic:${t.id},number:${JSON.stringify(twin.number)}})'>Number it ${stEsc(twin.number)}, as in the curriculum</button>` : '') + `<button class="st-mini" onclick="curAdd(${t.id})">it is in the curriculum</button>`; } if (t.title_differs) chips += `<span class="st-tag ${t.glue ? 'st-t-miss' : 'st-t-off'}">curriculum: ${stEsc(t.curriculum_title)}</span>` + `<button class="st-mini" style="font-weight:700" onclick='structOp({op:"rename",topic:${t.id},title:${JSON.stringify(t.curriculum_title)}})'>Use curriculum title</button>` + `<button class="st-mini" title="Only if the curriculum pages say otherwise" onclick="curRetitle(${t.id})">curriculum is wrong</button>`; if (t.numbered && t.has_section === false) chips += t.other_section ? `<span class="st-tag st-t-nosec">no notes yet: the notes section ${stEsc(t.number)} is about "${stEsc(t.other_section)}"</span>` : t.thin ? '<span class="st-tag st-t-nosec">empty notes section</span>' : `<span class="st-tag st-t-nosec">no notes section</span><button class="st-mini" onclick="structOp({op:'heading',topic:${t.id}})">add to notes</button>`; h += `<div class="st-row${t.depth > 1 ? ' st-sub' : ''}" data-id="${t.id}" style="padding-left:${pad}px">${t.number ? `<span class="st-num st-nedit" contenteditable="true" spellcheck="false" title="Click to change the number" data-id="${t.id}">${stEsc(t.number)}</span>` : '<span class="st-num">•</span>'}` + `<span class="st-title"><span class="st-edit" contenteditable="true" spellcheck="false" data-id="${t.id}">${stEsc(t.title)}</span>${t.covers ? '<span class="st-tag st-t-cov">covers the curriculum point: ' + stEsc(t.covers) + '</span>' + chips.replace(/<span[^>]*>\s*not in the curriculum\s*<\/span>/, '').replace(/<button[^>]*>\s*it is in the curriculum\s*<\/button>/, '') : chips}${(t.in_curriculum === false && !t.covers && String(t.number || '').split('.').length >= 3) ? '<button type="button" class="st-pbtn st-dem" data-topic="' + t.id + '">Make it a heading in ' + stEsc(String(t.number).split('.').slice(0, -1).join('.')) + '</button>' : ''}${(t.in_curriculum === false && !t.covers) ? (() => { const tw = ST.topics.find(x => x.id && x.id !== t.id && x.in_curriculum && stNorm(x.title) === stNorm(t.title)); return (tw ? '<span class="st-tag st-t-miss">repeats ' + stEsc(tw.number) + '</span>' : '') + '<button type="button" class="st-delbtn" onclick="structRemove(' + t.id + ')">Delete</button>'; })() : ''}${(t.points || []).length ? '<span class="st-pts">' + t.points.map((q, qi) => { const wk = q.where && q.where.startsWith('the text of'); const bt = (m, l) => `<button type="button" class="st-pbtn" data-topic="${t.id}" data-pt="${qi}" data-mode="${m}">${l}</button>`; return '<span class="st-pt' + (!q.where ? ' miss' : wk ? ' weak' : '') + '">' + stEsc(q.text) + (q.empty ? ': no notes yet' : !q.where ? ': not in the notes' : wk ? ': only mentioned in ' + stEsc(q.where) + ': check' : (q.head && q.head.toLowerCase() === q.text.toLowerCase()) ? '' : ': covered in ' + stEsc(q.where)) + (q.empty ? '' : !q.where ? bt('empty', 'Add heading') : wk ? bt('empty', 'Add heading') : (q.head && q.where.startsWith('"') && q.head.toLowerCase() !== q.text.toLowerCase() && t.points.filter(x => x.head === q.head).length === 1) ? bt('rename1', 'Use curriculum name') : '') + '</span>'; }).join('') + '</span>' : ''}</span><span class="st-btns">` + (t.depth === 1 ? `<button title="Copy the prompt, this topic's curriculum and its current notes for Claude" onclick="structClaude(${t.id})">Claude</button>` : '') + (t.numbered ? `<button title="Move up" onclick="structOp({op:'move',topic:${t.id},dir:'up'})">↑</button><button title="Move down" onclick="structOp({op:'move',topic:${t.id},dir:'down'})">↓</button>` : '') + `</span><span class="st-tools" contenteditable="false">` + (t.numbered ? `<button data-a="out" title="Up a level">&larr;</button><button data-a="in" title="Down a level">&rarr;</button>` : '') + `<button data-a="add" title="Add a topic below, same level">+ line</button>` + `<button data-a="bullet" title="Add an unnumbered point under it, as the curriculum lists (e.g. Knowledge, Management)">+ point</button>` + (t.numbered ? `<button data-a="sub" title="Add a sub-topic under it">+ sub</button><button data-a="join" title="Merge it into the topic above (its notes join that topic)">join &uarr;</button>` : '') + `<button data-a="del" title="Remove">&times;</button></span></div>`; } h += `<button class="st-addend" onclick="structAdd(null)">+ Add a main topic at the end</button>`; box.innerHTML = h; stCompareRestore(r.curriculum && r.curriculum.pdf); stApplyExtra(); } const stTopic = id => (ST && ST.topics.find(t => t.id === id)) || {}; // ── In-place editing (13 Oct 2026): the outline editor's tools on the chapter's own topic list. Every change goes // through the structure operations, so the notes headings and per-topic copies follow, and Undo works step by step. document.head.insertAdjacentHTML('beforeend', `<style> .st-edit{outline:none;border-radius:3px;padding:0 2px} .st-edit:hover{background:#F3F5F9}.st-edit:focus{background:#FFF7D6} .st-nedit{outline:none;border-radius:3px;cursor:text}.st-nedit:hover{background:#F3F5F9}.st-nedit:focus{background:#FFF7D6} .st-pts{display:block;margin-top:4px;font-size:12px;line-height:1.7}.st-pt{display:block;margin:0;padding-left:16px;color:#067647}.st-pt.miss{color:#B42318;font-weight:600}.st-pt.weak{color:#B54708;font-weight:600}.st-pbtn{margin-left:8px;font-size:11px;font-weight:600;padding:1px 9px;border:1px solid currentColor;border-radius:10px;background:#fff;color:inherit;cursor:pointer}.st-pbtn:hover{background:#FFF7D6}.st-delbtn{margin-left:8px;font-size:11px;font-weight:600;padding:1px 9px;border:1px solid #B42318;border-radius:10px;background:#fff;color:#B42318;cursor:pointer}.st-delbtn:hover{background:#FEF2F2}.st-arr{margin-left:8px;font-size:12px;font-weight:700;padding:3px 12px;border:1px solid #1E40AF;border-radius:12px;background:#1E40AF;color:#fff;cursor:pointer}.st-arr:hover{background:#1E3A8A}.st-pick{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:9999;display:flex;align-items:center;justify-content:center}.st-pick-box{background:#fff;border-radius:10px;padding:16px;max-width:680px;width:92%;max-height:80vh;overflow:auto;font-size:13px;line-height:1.5}.st-pick-box button{display:block;width:100%;text-align:left;margin-top:8px;padding:8px 10px;border:1px solid #D0D5DD;border-radius:6px;background:#F9FAFB;cursor:pointer;font-size:13px;line-height:1.5}.st-pick-box button:hover{background:#FFF7D6}.st-pick-box .st-pick-cancel{text-align:center;background:#fff}.st-t-weak{background:#FFFAEB!important;color:#B54708!important}.st-t-cov{background:#ECFDF3!important;color:#067647!important} .st-tools{display:none;flex-basis:100%;margin:3px 0 2px;gap:2px} .st-row:focus-within .st-tools,.st-row.st-active .st-tools{display:flex} .st-row.st-active{background:#FFFBEB;box-shadow:inset 3px 0 0 #E8A020} .st-tools button{border:1px solid #E5E8EE;background:#fff;border-radius:4px;cursor:pointer;font-size:11.5px;padding:1px 6px;color:#334155} .st-tools button:hover{background:#E5E8EE} .st-help{margin:2px 10px 6px;font-size:11.5px;color:#6B7B8D;line-height:1.4} .st-new{display:block;margin:2px 0 4px;padding:3px 6px;border:1px dashed #94A3B8;border-radius:4px;background:#FFFBEB;font-size:12.5px;outline:none} .st-new:empty::before{content:"type the title, then Enter (Esc cancels)";color:#94A3B8} </style>`); function stCaretSplit(el) { const sel = getSelection(); if (!sel.rangeCount) return [el.textContent.trim(), '']; const rg = sel.getRangeAt(0), pre = document.createRange(); pre.selectNodeContents(el); pre.setEnd(rg.startContainer, rg.startOffset); const k = pre.toString().length, full = el.textContent; return [full.slice(0, k).trim(), full.slice(k).trim()]; } function stNewRow(row, id, where) { document.querySelectorAll('.st-new').forEach(x => x.remove()); const n = document.createElement('div'); n.className = 'st-new'; n.contentEditable = 'true'; n.dataset.id = id; n.dataset.where = where; n.style.marginLeft = (where === 'below' ? 14 : 28) + 'px'; if (where === 'bullet') n.dataset.ph = 'type the point, then Enter (Esc cancels)'; row.after(n); n.focus(); } (function () { const box = document; // the panel is redrawn on every load, so listen on the page box.addEventListener('mousedown', e => { if (e.target.closest('.st-tools')) e.preventDefault(); }); // number editing: click the number, type, Enter saves (renumber keeps notes, questions and sub-topics), Esc cancels window.stArrange = async () => { // PREVIEW FIRST: the server makes every change, lists them, and rolls them back. 15 Oct 2026 const p = await api(`/api/chapter/${CHAPTER_ID}/structure/`, 'POST', {op: 'arrange', preview: 1}); if (!p.ok) { alert(p.error || 'That did not work.'); return; } const msg = p.message || ''; if (/^Nothing to arrange/.test(msg)) { alert(msg); return; } if (!confirm(msg.replace('PREVIEW (nothing saved yet). ', '') + '\n\nPress OK to make these changes. Each one shows in Last change and can be undone.')) return; structOp({op: 'arrange'}); }; // Buttons on curriculum points: Add heading / Use curriculum name / move a paragraph. 13 Oct 2026 if (!window._stPtBound) { window._stPtBound = true; document.addEventListener('click', e => { const dr = e.target.closest('.st-dropbtn'); if (dr) { e.preventDefault(); e.stopPropagation(); if (confirm('Delete the notes section "' + dr.dataset.num + ' ' + dr.dataset.title + '"?\n\nIts text is removed from the notes and the numbers after it close up. Undo restores it.')) structOp({op: 'drop', number: dr.dataset.num}); return; } const dm = e.target.closest('.st-dem'); if (dm) { e.preventDefault(); e.stopPropagation(); const t = ST.topics.find(x => String(x.id) === dm.dataset.topic); if (!t) return; const par = String(t.number).split('.').slice(0, -1).join('.'); const nt = prompt('"' + t.number + ' ' + t.title + '" is not in the curriculum. It will leave the topic list and become a heading inside ' + par + ', with all its notes text kept.\n\nHeading title (correct it to what the text is about):', t.title); if (nt && nt.trim()) structOp({op: 'demote', topic: t.id, title: nt.trim()}); return; } const b = e.target.closest('.st-pbtn'); if (!b) return; e.preventDefault(); e.stopPropagation(); const t = ST.topics.find(x => String(x.id) === b.dataset.topic); if (!t) return; const q = t.points[+b.dataset.pt], base = {op: 'point', topic: t.id, point: q.text}; const pick = (title, items, done) => { const d = document.createElement('div'); d.className = 'st-pick'; d.innerHTML = '<div class="st-pick-box"><div style="margin-bottom:6px">' + title + '</div>' + items.map((x, i) => '<button type="button" data-i="' + i + '">' + x.html + '</button>').join('') + '<button type="button" data-i="-1" class="st-pick-cancel">Cancel</button></div>'; d.addEventListener('click', ev => { const bb = ev.target.closest('button'); if (!bb && ev.target !== d) return; d.remove(); const i = bb ? +bb.dataset.i : -1; if (i >= 0) done(items[i]); }); document.body.appendChild(d); }; if (b.dataset.mode === 'rename1') structOp(Object.assign(base, {mode: 'rename', heading: q.head})); else if (b.dataset.mode === 'empty' && (t.free_heads || []).length) pick('Is <b>' + stEsc(q.text) + '</b> already in the notes under another heading in <b>' + stEsc(t.number) + '</b>? Pick it and it is renamed to <b>' + stEsc(q.text) + '</b>, its text kept.', t.free_heads.map(x => ({html: stEsc(x), v: x})).concat([{html: '<b>It is not in the notes:</b> add a new heading <b>' + stEsc(q.text) + '</b>', v: null}]), it => structOp(Object.assign(base, it.v ? {mode: 'rename', heading: it.v} : {mode: 'empty'}))); else if (b.dataset.mode === 'empty') structOp(Object.assign(base, {mode: 'empty'})); else if (b.dataset.mode === 'rename') pick('Which heading in <b>' + stEsc(t.number) + '</b> covers <b>' + stEsc(q.text) + '</b>? It will be renamed to <b>' + stEsc(q.text) + '</b>. Its text stays as it is.', (t.free_heads || []).map(x => ({html: stEsc(x), v: x})), it => structOp(Object.assign(base, {mode: 'rename', heading: it.v}))); else pick('<b>' + stEsc(q.text) + '</b> is mentioned in these paragraphs. Pick the one that is about it: it moves under the new heading <b>' + stEsc(q.text) + '</b>.', (q.paras || []).map(x => ({html: stEsc(x.length > 450 ? x.slice(0, 450) + '...' : x), v: x})).concat([{html: '<b>None of these:</b> add the heading empty and leave the text where it is', v: null}]), it => structOp(Object.assign(base, it.v ? {mode: 'move', para: it.v} : {mode: 'empty'}))); }, true); } box.addEventListener('focusin', e => { const el = e.target.closest('.st-nedit'); if (el) el.dataset.orig = el.textContent.trim(); }); box.addEventListener('keydown', e => { const el = e.target.closest('.st-nedit'); if (!el) return; if (e.key === 'Enter') { e.preventDefault(); el.blur(); } else if (e.key === 'Escape') { e.preventDefault(); el.textContent = el.dataset.orig; el.blur(); } }); box.addEventListener('focusout', e => { const el = e.target.closest('.st-nedit'); if (!el) return; const v = el.textContent.trim().replace(/\.$/, ''), old = el.dataset.orig; if (!v || v === old) { el.textContent = old; return; } if (!/^\d+(\.\d+)*$/.test(v)) { el.textContent = old; alert('Use a number like 2.1'); return; } structOp({op: 'renumber', id: +el.dataset.id, topic: +el.dataset.id, from: old, number: v}); }); box.addEventListener('focusin', e => { const el = e.target.closest('.st-edit'); if (el && el.dataset.orig === undefined) el.dataset.orig = el.textContent; }); box.addEventListener('paste', e => { if (!e.target.closest('.st-edit, .st-new')) return; e.preventDefault(); document.execCommand('insertText', false, (e.clipboardData.getData('text/plain') || '').replace(/\s+/g, ' ')); }); box.addEventListener('keydown', e => { const nw = e.target.closest('.st-new'); if (nw) { if (e.key === 'Escape') { nw.remove(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const title = nw.textContent.replace(/\s+/g, ' ').trim(); if (!title) return; structOp(nw.dataset.where === 'bullet' ? {op: 'add', topic: +nw.dataset.id, where: 'inside', unnumbered: true, title: title} : {op: 'add', topic: +nw.dataset.id, where: nw.dataset.where, title: title}); return; } const el = e.target.closest('.st-edit'); if (!el) return; if (e.key === 'Escape') { el.textContent = el.dataset.orig || el.textContent; el.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const id = +el.dataset.id, [before, after] = stCaretSplit(el), orig = (el.dataset.orig || '').trim(); if (!after) { if (before && before !== orig) { el.dataset.saving = '1'; structOp({op: 'rename', topic: id, title: before}); } else stNewRow(el.closest('.st-row'), id, 'below'); return; } // split: the part before the caret stays; the rest (its own number dropped) becomes the next topic const m = after.match(/^(\d+(?:\s?\.\s?\d+)*)\.?\s+(.+)$/); el.dataset.saving = '1'; structOp({op: 'batch', ops: [{op: 'rename', topic: id, title: before}, {op: 'add', topic: id, where: 'below', title: m ? m[2] : after}]}); }); box.addEventListener('focusout', e => { const el = e.target.closest('.st-edit'); if (!el || el.dataset.saving) return; const now = el.textContent.replace(/\s+/g, ' ').trim(), orig = (el.dataset.orig || '').trim(); delete el.dataset.orig; if (now && now !== orig) { el.dataset.saving = '1'; structOp({op: 'rename', topic: +el.dataset.id, title: now}); } else if (!now) el.textContent = orig; }); box.addEventListener('click', e => { const b = e.target.closest('.st-tools button'); if (!b) return; e.preventDefault(); const row = b.closest('.st-row'), id = +row.dataset.id, t = stTopic(id), a = b.dataset.a; if (a === 'out' || a === 'in') structOp({op: 'relevel', topic: id, dir: a}); else if (a === 'add') stNewRow(row, id, 'below'); else if (a === 'sub') stNewRow(row, id, 'inside'); else if (a === 'bullet') stNewRow(row, id, 'bullet'); else if (a === 'join') { if (ST.topics.some(x => x.id && x.number && x.number.startsWith(t.number + '.'))) { alert(t.number + ' has sub-topics: join them into it first, or move them.'); return; } if (confirm('Merge ' + t.number + ' ' + t.title + ' into the topic above it?\n\nIts notes text joins that topic; the topics after it move up one. Undo restores it.')) structOp({op: 'remove', topic: id, notes: 'merge', close_gap: true}); } else if (a === 'del') structRemove(id); }); })(); function structAdd(id, num) { const t = id ? stTopic(id) : (num ? (ST.topics.find(x => x.notes_only && x.number === num) || {number: num}) : null); const opts = [['above', 'Above it (takes its number; the ones after move down one)'], ['below', 'Below it, at the same level'], ['inside', 'Inside it, as a sub-topic (after its last sub-topic)']]; if (id) opts.push(['example', 'Inside it, as an unnumbered line (an example the curriculum lists)']); let body = t ? `<div style="font-size:13px;margin-bottom:6px;">Next to <b>${stEsc(t.number)} ${stEsc(t.title)}</b>:</div>` + opts.map(([v, l]) => `<label class="st-opt"><input type="radio" name="stWhere" value="${v}"${v === 'below' ? ' checked' : ''}> ${l}</label>`).join('') : `<div style="font-size:13px;margin-bottom:6px;">A new main topic after the last one.</div>`; body += inp('stTitle', 'Title, exactly as in the curriculum', ''); openModal(t ? 'Add a topic' : 'Add a main topic', body, () => { const w = t ? (document.querySelector('input[name=stWhere]:checked') || {}).value : 'end'; structOp({op: 'add', topic: id || null, ref_number: id ? null : (num || null), where: w === 'example' ? 'inside' : w, unnumbered: w === 'example', title: g('stTitle')}); }); } function structRename(id) { const t = stTopic(id); openModal('Rename ' + (t.number || 'line'), inp('stTitle', 'Title, exactly as in the curriculum', t.title), () => structOp({op: 'rename', topic: id, title: g('stTitle')})); } function structRemove(id) { // The word count and the curriculum twin are shown before anything is deleted. 14 Oct 2026 const t = stTopic(id), w = t.words || 0; const warn = w ? `<div style="font-size:13px;margin:6px 0 8px;padding:7px 9px;background:#FEF2F2;color:#991B1B;border-radius:6px;">Its notes section has <b>${w} words</b>. "Delete its notes section too" deletes them.` + (t.twin ? ` This looks like the curriculum topic <b>${stEsc(t.twin)}</b>: change its number to ${stEsc(t.twin)} instead (click the number), so the notes move with it.` : '') + `</div>` : ''; openModal('Remove ' + (t.number || 'line'), `<div style="font-size:13px;margin-bottom:8px;">Remove <b>${stEsc(t.number)} ${stEsc(t.title)}</b>?</div>` + warn + `<label class="st-opt"><input type="radio" name="stNotes" value="delete" checked> Delete its notes section too</label>` + `<label class="st-opt"><input type="radio" name="stNotes" value="merge"> Keep its notes text (it joins the topic above)</label>` + (w ? `<label class="st-opt" style="color:#991B1B;"><input type="checkbox" id="stWords"> I understand: ${w} words of notes will be deleted</label>` : '') + (t.numbered ? `<label class="st-opt"><input type="checkbox" id="stGap" checked> Renumber the ones after it to close the gap</label>` : ''), () => { const notes = (document.querySelector('input[name=stNotes]:checked') || {}).value; if (w && notes === 'delete' && !(document.getElementById('stWords') || {}).checked) { alert('Tick "I understand: ' + w + ' words of notes will be deleted", or choose to keep its notes text.'); return; } structOp({op: 'remove', topic: id, notes: notes, close_gap: !!(document.getElementById('stGap') || {}).checked, confirm_words: true}); }); } // Writer rules: the HOUSE RULES block (item 11) is a copy of content/notes_rules.py, the one source shared with // the notes writer (content/notes_brief.py) and the API (company_apinotes). Change the rules THERE, then run // python3 content/notes_rules.py --write; content/tests_notes_check.py fails while this copy differs. const ST_PROMPT = `You are a PhD-level lecturer with 20 years experience writing world-class TVET textbooks for Profplanet, Kenya's premium TVET learning platform. You are completing the notes for ONE topic so that they match the CDACC curriculum exactly. Below are: CURRICULUM TOPICS (this topic, every numbered curriculum item under it, and the curriculum points listed under each item, indented by level) and CURRENT NOTES (this topic's notes in Markdown, exactly as on the platform). Return the WHOLE topic's notes, complete and ready to paste back: 1. Start with the topic's heading exactly as it is now, followed by its opening paragraph. 2. EVERY numbered curriculum item must have its own heading with its exact number and title, at its level: ## for X.Y, ### for X.Y.Z, #### for X.Y.Z.W, ##### for deeper, in curriculum order. Never rename, merge or renumber a curriculum item, and never replace the curriculum's list with a different one. 3. Lines marked "(curriculum point)" are points the curriculum lists under the item above them. Each point gets its own unnumbered heading with the point's exact words, as a #### heading under a ## or ### item, or ##### under a #### item, placed straight after the item's opening paragraph, in the curriculum's order. 4. ONE SECTION PER THING: if the current notes already cover a curriculum item or point under a different heading or wording (for example "Government and Regulatory Bodies" for the point "Government agencies", or "Employee Expertise and Experience" for "Personnel"), move that text under the curriculum's heading and merge it with anything already written there. Never leave two sections on the same thing. A heading that covers two points (for example "Accessibility and Format") is split into one section per point. Before you finish, read the whole topic again: each idea, list item and example must appear only ONCE. If an extra section repeats what a curriculum section says, keep it in the curriculum section and delete it from the extra; delete leftover lines that repeat a section, too. Never repeat content that belongs to another topic of this chapter (listed under OTHER TOPICS IN THIS CHAPTER): leave it to that topic. 5. Keep existing text, tables, images and their links, and the practice questions block at the end. Sections that are not in the curriculum (extras) are welcome when they add relevant material the curriculum does not mention: the curriculum is the minimum, not the limit. But nothing is said twice: anything an extra repeats from a curriculum section is merged into that curriculum section, and the extra keeps only what is genuinely additional. An extra left with nothing new is dropped. An extra about another topic of this chapter is left to that topic. Kept extras go under unnumbered #### headings inside the curriculum item they belong to (or after this topic's last curriculum item), with titles that say what they add. An extra never gets a number: a numbered heading that is not a curriculum item shows as a stray sub-topic and the save refuses it. 6. Every curriculum heading must have text under it. Write each missing or empty section to the platform's standard: open with 2 to 4 sentences of developed explanation (what it is, why it matters, how it works in practice) with a concrete example. Named groups of points go under an unnumbered heading one level below. Lists have at least 5 points, each explained in 1 to 3 sentences. Procedures are numbered steps (at least 6), each explained. Tools and equipment get a | Name | Specification | Use | table first. 7. Style: no em dashes (use commas or colons). Write "Example:", never "Kenyan Example". Do not bold words inside paragraph text; bold only the label at the start of a list item and table header cells. Use Ksh. Examples come from the workplaces this unit trains for (a welding workshop for welding, a salon for cosmetology, a hotel or restaurant for catering); vary them within that trade, never borrow settings from unrelated fields, and do not name real organisations or invent incidents. No summary sentence after a list. Tables only for specifications or genuine comparisons. 8. Images: keep every image exactly as it is in the current notes, character for character (the Markdown image line or <img>/<figure>/<div> tag, its link and its caption), in the same place relative to its text. Never remove, describe, move or alter an image. 9. Keep every HTML block exactly (for example the <div class="practice-qs" markdown="1"> practice questions block), including its opening and closing tags. 10. Formulas and calculations: write formulas as plain text with ordinary symbols (×, ÷, ², √, =), never LaTeX notation, because the platform does not display it. Show calculations as formula, substitution, working, then the result with units, each on its own line. 11. HOUSE RULES, they apply to every topic: - The CURRICULUM TOPICS list is the source of truth: write about each listed item and point, with its exact heading, and nothing the list contradicts. The curriculum is the minimum, not the limit. - Do not repeat content of the other topics of the chapter or unit: leave it to them. When sibling topics split one subject (Documents / Content of documents; Measurement / Benefits / Key metrics; Order consolidation / Definition / Methods), each covers only what its own title names; a later topic that meets a term again refers back in one sentence instead of defining it again. Refer to another topic by its title, never by its number ("see Netiquette principles", not "see 4.1"). - A "Types of" or list topic covers only the listed items: do not add other product families or categories as extra sections (they belong to other topics of the unit); delete any such section already in the notes. - Content from another unit or learning outcome (post-harvest or marketing material in a nursery topic, processing tools in a planting topic, another unit's practicals) is removed, not kept. - Heading levels: ## for the topic (X.Y), ### for each numbered sub-topic (X.Y.Z), #### for X.Y.Z.W and for every other heading inside the topic. ### is only for a numbered sub-topic. - A numbered heading is used ONLY for a number in the CURRICULUM TOPICS list or a topic row of this topic. Never add any other numbered heading: extra material goes under unnumbered #### headings inside the curriculum item it belongs to. Old notes headings are not curriculum items. - Sections of the CURRENT NOTES that carry a number not in the list are old generated sections: keep their useful, correct content as unnumbered #### headings inside the right curriculum item, or drop them; never keep their numbers and never refer to them by number. - When CDACC skips a parent number (it prints 2.2.1.1 with no 2.2.1 above it), the parent that the list gives is still written as its own heading at its level. - Headings go no deeper than ####, except a curriculum point one level below a #### sub-item, which is a ##### heading with the point's words. Below that use a short lead-in line. - Every curriculum heading keeps its exact number and the wording and capitals of the topic rows and outline (Labelling, not Labeling). Every heading the save requires must be present. - Correct CDACC misspellings and slips in headings and text, never copy them (Fibre, Crescent, packer, Crochet, Composting, Filing, Deglutition, trial balance, auger not augur, shade nets not shed nets, and/or not and/ or). Capitalise the first word of every heading. - Never end a heading with a full stop. - Never copy box, tick or bullet signs from the curriculum into headings or text. - Content about the whole topic goes under the ## heading before its first ### sub-topic, never inside the last sub-topic. Opening paragraphs go straight to the subject, with no filler about workplaces outside the trade. - A "Practice" line of the curriculum is a task, not a heading. - Do not write practice questions or a Practice Questions heading in the notes: they live in a separate store and the platform shows them in a box at the end of the topic. An existing practice questions block is kept exactly as it is. - British spelling everywhere, headings included: colour, flavour, behaviour, labour, fibre, centre, litre, programme (a software program keeps program), labelling, levelling, modelling, and -ise forms in headings (Sterilising, Sanitiser) and body text (organise, analyse, optimise, utilise, minimise, fertiliser). - No bold inside sentences. Bold only a short label at the start of a list item, written with the colon inside the bold and in sentence case (**Washing machines:**), and table header cells. The opening words of a numbered step are not bolded unless they are a label ending in a colon. - No LaTeX (no $ or $$ maths, no frac, times or sqrt commands): the platform shows it as raw code. Write formulas as plain text with ordinary symbols (×, ÷, ², √, =). Write negation in plain words or a single overbar letter (NOT (A + B), Ā), never a trailing overline character. Write a matrix in a fenced code block with its columns aligned. Tally marks have exactly as many strokes as the frequency, grouped in fives. - No em dashes: use a colon, a comma or a full stop. - Leave a blank line before every list. - Join words the curriculum wraps with a hyphen (non-metals, not non- metals). - Every example, setting and procedure comes from the workplaces this unit and course train for, not from the topic title read on its own. Do not use SACCOs, banks, county offices, hospitals, schools or churches as example settings unless the unit is about them. - Examples stay hypothetical ("a bakery may..."): never invented events, incidents, cases or fines, and never real companies, hotels, hospitals, banks or towns named as evidence. - Do not invent standards, regulations, codes, statistics, prices, figures or brand claims: when unsure, teach the general principle instead. Never claim what is "most common", "required by Kenyan law" or "popular in Kenya" unless it is certain. No unproven superlatives ("most used", "holds attention longest", "remember better"): say "widely used", "helps attention". - Facts, formulas and procedures must be correct for a Kenyan TVET learner at this level, and every example must make physical and dimensional sense. - Recompute every worked example before you finish: it must agree with the tables and rules in the same notes. Carry full precision through the working and round only the final answer. - Single-use items (lancets, needles, syringes, disposable gloves) are never described as cleaned or reused. - Product facts must be current: retired products are named as retired (Skype was retired in May 2025; Microsoft Publisher support ended in October 2026; use Microsoft Teams, Word, Canva, Scribus or another current tool for examples; Adobe Animate is in maintenance mode since February 2026; Premiere Rush was replaced by the free Premiere mobile app on iPhone and Android; Final Cut Pro and Motion are also in Apple Creator Studio since January 2026; Affinity by Canva is free; Premiere Pro 2025 removed the Essential Graphics panel: titles are edited in the Properties panel, templates in the Graphics Templates panel). Affinity Publisher, Designer and Photo were withdrawn in September 2025 (one free Affinity app by Canva); Microsoft 365 apps list Windows 11 (1.6 GHz 2-core), Office 2024 a 1.1 GHz 2-core CPU; on Windows 10 they get security fixes only, to October 2028. Kenyan public procurement runs on the National Treasury e-GP system (main platform since July 2025, its mandatory use contested in court); IFMIS remains for budgets and payments. Check any product claim made after mid-2025 rather than writing it from memory. - Laws and codes must be the ones in force: Kenya's media code is the Code of Conduct for Media Practice, 2025 (Legal Notice 88 of 2025, under the Media Council Act; it replaced the Code of Conduct for the Practice of Journalism and applies to journalists, media practitioners and media enterprises; watershed 5.00 am to 10.00 pm); programme content standards rest with the Media Council of Kenya, not the Communications Authority. Check any law or code by name before citing it, and check what it says before writing that it permits or requires something: the Alcoholic Drinks Control Act, 2010 forbids selling alcohol by automatic vending machine (section 30(1)) and selling or supplying it to anyone under 18 (section 28(1)). - Notes speak to the learner about the subject: never mention CDACC, the curriculum document, its misprints or its numbering. Do not reuse one example across topics of a unit: vary them. - Metric units only. Money is in Ksh. - Cite a Kenyan Act only by its enacted title and year as published on Kenya Law. A Bill is not an Act: the Food and Feed Safety Control Co-ordination Bill (National Assembly Bill No. 21 of 2023) is not the "Food and Feed Safety Control Coordination Act, 2023". If only a Bill is found, or an Act's title and start date cannot be confirmed, name the laws already in force instead (for food: the Public Health Act and the Food, Drugs and Chemical Substances Act, enforced by county public health officers). - One colour-coded chopping board scheme everywhere: red raw meat and raw poultry, blue raw fish, yellow cooked meat, brown raw vegetables, green salad and fruit, white bakery and dairy. Hot holding at 63 °C or above. Waste: hazardous waste (chemicals, used oil, batteries, fluorescent tubes) is not the same as sharps (broken glass, blades); check each material's degradability (cellophane is biodegradable; plastic film is not). END OF HOUSE RULES. 12. OUTPUT FORMAT, CRITICAL: return the topic's raw Markdown inside ONE code block that starts with a line \`\`\`\`markdown and ends with a line \`\`\`\` (four backticks), so it can be copied with the code block's Copy button exactly as written. Nothing before the block. After the block, write one line: "Removed as off-topic:" followed by the headings you left out, or "none".`; async function structClaude(id) { const t = stTopic(id), i0 = ST.topics.indexOf(t), pre = t.number + '.', lines = [], seen = new Set(); for (let k = i0; k < ST.topics.length; k++) { const x = ST.topics[k]; if (k > i0 && x.number && !x.number.startsWith(pre)) break; if ((x.notes_only && !x.in_curriculum) || x.in_curriculum === false) continue; const ind = ' '.repeat(Math.max(0, (x.depth || 1) - (t.depth || 1))); if (!x.number && seen.has(stNorm(x.title))) continue; lines.push(ind + (x.number ? x.number + ' ' : '(curriculum point) ') + (x.curriculum_title || x.title)); (x.points || []).forEach(q => { seen.add(stNorm(q.text)); lines.push(ind + ' (curriculum point) ' + q.text); }); } const others = ST.topics.filter(x => x.id && x.number && x !== t && !x.number.startsWith(pre) && x.in_curriculum !== false).map(x => x.number + ' ' + (x.curriculum_title || x.title) + ((x.points || []).length ? ': ' + x.points.map(q => q.text).join(', ') : '')); const r = await api(`/api/topic/${id}/note/`); const text = ST_PROMPT + '\n\nCURRICULUM TOPICS:\n' + lines.join('\n') + '\n\nOTHER TOPICS IN THIS CHAPTER (their content belongs there, do not repeat it):\n' + others.join('\n') + '\n\nCURRENT NOTES:\n' + ((r && r.content_md) || '(this topic has no notes yet)'); try { await navigator.clipboard.writeText(text); toast('✅ Copied ' + t.number + ': paste it into Claude'); } catch (e) { openModal('Copy this into Claude', `<textarea style="width:100%;height:320px;font-size:12px;">${stEsc(text)}</textarea>`, closeModal); } } function curAdd(id) { const t = stTopic(id); openModal('Did our curriculum copy miss ' + t.number + '?', `<div style="font-size:13px;margin-bottom:8px;">Only if the curriculum pages show <b>${stEsc(t.number)}</b> and our saved copy missed it. This corrects the copy the check uses; the topic itself is not changed. Everything is recorded.</div>` + inp('cfTitle', 'Title exactly as in the curriculum pages', t.title) + inp('cfWhy', 'Why: what the pages show', ''), () => structOp({op: 'cur_add', number: t.number, title: g('cfTitle'), reason: g('cfWhy')})); } function curRetitle(id) { const t = stTopic(id); openModal('Correct the curriculum copy for ' + t.number, `<div style="font-size:13px;margin-bottom:8px;">Our saved copy says "<b>${stEsc(t.curriculum_title)}</b>". Correct it only if the curriculum pages say otherwise. If it is the topic list that is wrong, use ✎ on the topic instead.</div>` + inp('cfTitle', 'Title exactly as in the curriculum pages', t.curriculum_title) + inp('cfWhy', 'Why: what the pages show', ''), () => structOp({op: 'cur_retitle', number: t.number, title: g('cfTitle'), reason: g('cfWhy')})); } function structAttached(body, r) { const what = Object.entries(r.attached || {}).map(([k, n]) => n + ' ' + k).join(', '); openModal('Something is attached', `<div style="font-size:13px;margin-bottom:8px;">This topic has <b>${stEsc(what)}</b> attached. What should happen to them?</div>` + (r.target ? `<label class="st-opt"><input type="radio" name="stAtt" value="move"> Move them to <b>${stEsc(r.target)}</b></label>` : '') + `<label class="st-opt"><input type="radio" name="stAtt" value="delete" checked> Delete them with the topic (flags and lessons are only detached)</label>` + `<div style="font-size:12px;color:#6B7B8D;margin-top:6px;">Undo restores everything, these included.</div>`, () => structOp(Object.assign({}, body, {attached: (document.querySelector('input[name=stAtt]:checked') || {}).value}))); } const stNorm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); function structChapterTitle() { const o = String(ST.curriculum.outcome || '').trim().replace(/\.+$/, ''); openModal('Rename the chapter', `<div style="font-size:13px;margin-bottom:8px;">Only if the content table prints this wording.<br>Now: <b>${stEsc(ST.chapter_title)}</b><br>Our copy: <b>${stEsc(ST.curriculum.outcome)}</b></div>` + inp('stCT', 'Chapter title, exactly as the content table prints the outcome', o), () => structOp({op: 'chapter_title', title: g('stCT')})); } function curOutcome() { openModal('Correct our curriculum copy', `<div style="font-size:13px;margin-bottom:8px;">Our copy says "<b>${stEsc(ST.curriculum.outcome)}</b>"; the chapter says "<b>${stEsc(ST.chapter_title)}</b>". Correct the copy only if the content table prints the chapter's wording. The chapter is not changed.</div>` + inp('cfTitle', 'Outcome exactly as the content table prints it', ST.chapter_title) + inp('cfWhy', 'Why: what the pages show', ''), () => structOp({op: 'cur_outcome', title: g('cfTitle'), reason: g('cfWhy')})); } function structUndo() { if (confirm('Undo the last structure change on this chapter?')) structOp({op: 'undo'}); } // After a change: fetch the page quietly, swap in the fresh notes, redraw the topic list, and keep both scroll // positions, instead of reloading and throwing the editor back to the top. Any failure falls back to a reload. async function stRefresh(msg) { const sb = document.querySelector('.two-col > .sidebar'), y = window.scrollY, sy = sb ? sb.scrollTop : 0; try { const html = await (await fetch(location.href, {credentials: 'same-origin', cache: 'no-store'})).text(); const fresh = new DOMParser().parseFromString(html, 'text/html').querySelector('.notes-body'); const cur = document.querySelector('.notes-body'); if (!fresh || !cur) throw new Error('no notes'); cur.innerHTML = fresh.innerHTML; if (window.MathJax && MathJax.typesetPromise) { try { await MathJax.typesetPromise([cur]); } catch (e) {} } await structLoad(); window.scrollTo(0, y); if (sb) sb.scrollTop = sy; if (typeof toast === 'function') toast('Saved: ' + (msg || 'done')); } catch (e) { try { sessionStorage.setItem('pp_struct_reopen', msg || '1'); } catch (e2) {} location.reload(); } } async function structOp(body) { const r = await api(`/api/chapter/${CHAPTER_ID}/structure/`, 'POST', body); if (!r.ok && r.code === 'children') { if (confirm('Remove it together with its sub-topics?\n\n' + (r.children || []).join('\n') + '\n\nTheir notes sections are deleted too. Undo restores everything.')) structOp(Object.assign({}, body, {branch: true, notes: 'delete'})); return; } if (!r.ok && r.code === 'attached') { structAttached(body, r); return; } if (!r.ok) { alert(r.error || 'That did not work.'); return; } if (typeof closeModal === 'function') { try { closeModal(); } catch (e) {} } stRefresh(r.message); } (function () { const orig = window.toggleEditMode; if (typeof orig !== 'function') return; window.toggleEditMode = function () { orig(); const on = typeof editMode !== 'undefined' && editMode; const box = document.getElementById('structTree'); if (box) box.style.display = on ? 'block' : 'none'; document.querySelectorAll('.sidebar-body > .sidebar-item').forEach(el => el.style.display = on ? 'none' : 'flex'); const ab = document.getElementById('addTopicBtn'); if (ab) ab.style.display = 'none'; if (on && box) structLoad(); }; let msg = null; try { msg = sessionStorage.getItem('pp_struct_reopen'); sessionStorage.removeItem('pp_struct_reopen'); } catch (e) {} if (msg && document.getElementById('editModeBtn')) { const go = () => { window.toggleEditMode(); if (msg !== '1') toast('✅ ' + msg); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else setTimeout(go, 0); } })(); // ── Verify ── function toggleVerify(chapterId) { fetch(`/chapter/${chapterId}/verify/`,{method:'POST',headers:{'X-CSRFToken':CSRF,'Content-Type':'application/json'}}) .then(r=>r.json()).then(data=>{ const btn=document.getElementById('verifyBtn'); if(data.verified){btn.textContent='✅ Verified';btn.style.background='#E8F5E9';btn.style.color='#1E8449';btn.style.borderColor='#1E8449';} else{btn.textContent='☐ Mark Verified';btn.style.background='white';btn.style.color='#6B7B8D';btn.style.borderColor='#CBD5E0';} if(data.all_verified&&data.canonical){alert(data.canonical.is_new_canonical?'🎯 Unit is now canonical!':'🔗 Linked to existing canonical unit.');} }); } // Image delete functionality function deleteImage(btn) { const imgPath = btn.getAttribute('data-path'); const chapterId = btn.getAttribute('data-chapter'); if (!confirm('Remove this image from the notes?')) return; fetch('/api/delete-image/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({chapter_id: chapterId, image_path: imgPath}) }) .then(r => r.json()) .then(data => { if (data.ok) { btn.closest('.img-wrapper').remove(); } else { alert('Error: ' + (data.error || 'Unknown error')); } }); } // Wrap all images in notes-body with delete button overlay (edit mode only) function wrapImagesWithDelete() { const chapterId = typeof CHAPTER_ID !== 'undefined' ? CHAPTER_ID : null; if (!chapterId) return; if (typeof CAN_EDIT === 'undefined' || !CAN_EDIT) return; // only authorized editors get delete buttons document.querySelectorAll('.notes-body img').forEach(img => { if (img.closest('.img-wrapper')) return; const wrapper = document.createElement('div'); wrapper.className = 'img-wrapper'; wrapper.style.cssText = 'position:relative;display:inline-block;max-width:100%;'; img.parentNode.insertBefore(wrapper, img); wrapper.appendChild(img); const btn = document.createElement('button'); btn.innerHTML = '✕ Remove'; btn.className = 'img-delete-btn'; btn.setAttribute('data-path', img.getAttribute('src')); btn.setAttribute('data-chapter', chapterId); btn.onclick = function() { deleteImage(this); }; btn.style.cssText = 'display:none;position:absolute;top:4px;right:4px;background:var(--accent);color:white;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:12px;z-index:10;'; wrapper.appendChild(btn); }); } // Show/hide delete buttons based on edit mode document.addEventListener('DOMContentLoaded', function() { wrapImagesWithDelete(); // no-op unless CAN_EDIT }); </script> <script> (function(){ var fcEl=document.getElementById('sa-fc-data'); var mcEl=document.getElementById('sa-mcq-data'); if(fcEl){ var FC=JSON.parse(fcEl.textContent), i=0, flipped=false, order=FC.map(function(_,k){return k;}); var card=document.getElementById('sa-fc-card'),side=document.getElementById('sa-fc-side'),front=document.getElementById('sa-fc-front'),back=document.getElementById('sa-fc-back'),q=document.getElementById('sa-fc-q'),num=document.getElementById('sa-fc-num'),pts=document.getElementById('sa-fc-points'),cnt=document.getElementById('sa-fc-count'),dots=document.getElementById('sa-fc-dots'),strip=document.getElementById('sa-fc-strip'),cue=document.getElementById('sa-fc-cue'); function render(){ var c=FC[order[i]]; num.textContent=(i+1); q.textContent=c.q; pts.innerHTML=''; (c.points||[]).forEach(function(pt){ var li=document.createElement('li'); li.style.cssText='display:flex;gap:9px;align-items:flex-start;font-size:15px;line-height:1.5;color:var(--primary);'; li.innerHTML='<span style="color:#1E8449;font-size:17px;flex-shrink:0;">\u2713</span><span>'+pt+'</span>'; pts.appendChild(li); }); side.textContent=flipped?'Answer':'Question'; front.style.display=flipped?'none':'block'; back.style.display=flipped?'block':'none'; strip.style.background=flipped?'#E8F5E9':'#EBF2FA'; side.style.color=flipped?'#1E8449':'var(--primary)'; card.style.borderColor=flipped?'#1E8449':'#2E86C1'; num.style.background=flipped?'#1E8449':'#2E86C1'; cue.style.background=flipped?'#1E8449':'#2E86C1'; cue.innerHTML=flipped?'\u21bb Tap to see question':'\u21bb Tap card to reveal answer'; cnt.textContent=(i+1)+' / '+FC.length; dots.innerHTML=''; order.forEach(function(_,k){var d=document.createElement('div');d.style.cssText='flex:1;height:4px;border-radius:999px;background:'+(k===i?'#2E86C1':'#D6E2EE')+';';dots.appendChild(d);}); } card.onclick=function(){flipped=!flipped;render();}; window.saFcNext=function(){i=(i+1)%FC.length;flipped=false;render();}; window.saFcPrev=function(){i=(i-1+FC.length)%FC.length;flipped=false;render();}; window.saFcShuffle=function(){for(var k=order.length-1;k>0;k--){var j=Math.floor(Math.random()*(k+1));var t=order[k];order[k]=order[j];order[j]=t;}i=0;flipped=false;render();}; render(); } if(mcEl){ var MC=JSON.parse(mcEl.textContent), mount=document.getElementById('sa-quiz-mount'), score=0, answered=0; var pctEl=document.getElementById('sa-quiz-pct'), scoreEl=document.getElementById('sa-quiz-score'), barEl=document.getElementById('sa-quiz-bar'); MC.forEach(function(item,qi){ var wrap=document.createElement('div'); wrap.style.cssText='background:#fff;border:1px solid #E0E4E8;border-radius:12px;padding:16px 18px;margin-bottom:12px;'; var qp=document.createElement('p'); qp.textContent=(qi+1)+'. '+item.q; qp.style.cssText='font-size:15px;margin:0 0 12px;line-height:1.5;color:var(--primary);font-weight:600;'; wrap.appendChild(qp); item.options.forEach(function(opt,oi){ var b=document.createElement('button'); b.type='button'; b.textContent=opt; b.style.cssText='display:block;width:100%;text-align:left;margin-bottom:6px;padding:11px 14px;font-size:14px;border:1px solid #CBD5E0;border-radius:10px;background:#fff;cursor:pointer;color:var(--primary);transition:background .15s;'; b.onmouseover=function(){if(!wrap.dataset.done)b.style.background='#F4F8FC';}; b.onmouseout=function(){if(!wrap.dataset.done)b.style.background='#fff';}; b.onclick=function(){ if(wrap.dataset.done) return; wrap.dataset.done='1'; answered++; Array.prototype.forEach.call(wrap.querySelectorAll('button'),function(bb,bi){ bb.style.cursor='default'; if(bi===item.answer){bb.style.background='#E8F5E9';bb.style.borderColor='#1E8449';bb.style.color='#1E8449';bb.style.fontWeight='600';} else if(bi===oi){bb.style.background='#FDEDEC';bb.style.borderColor='var(--accent)';bb.style.color='#C0392B';} }); if(oi===item.answer) score++; if(item.why){var ex=document.createElement('p');ex.textContent=item.why;ex.style.cssText='font-size:13px;color:#6B7B8D;margin:8px 0 0;padding-top:8px;border-top:1px solid #EAEDF0;';wrap.appendChild(ex);} var pct=Math.round(score/MC.length*100); pctEl.textContent=Math.round(answered/MC.length*100>=0?score/MC.length*100:0)+'%'; pctEl.textContent=Math.round(score/MC.length*100)+'%'; scoreEl.textContent=score+' / '+MC.length; barEl.style.width=(answered/MC.length*100)+'%'; if(answered===MC.length){ var fp=Math.round(score/MC.length*100); var d=document.getElementById('sa-quiz-done'); d.style.display='block'; var msg, bg, col; if(fp>=80){msg='Excellent! You scored '+fp+'% ('+score+'/'+MC.length+'). You\'ve mastered this chapter.';bg='#E8F5E9';col='#1E8449';} else if(fp>=50){msg='Good effort \u2014 '+fp+'% ('+score+'/'+MC.length+'). Review the flashcards and try again to improve.';bg='#FEF9E7';col='var(--accent)';} else{msg='You scored '+fp+'% ('+score+'/'+MC.length+'). Go through the notes and flashcards, then retake the quiz.';bg='#FDEDEC';col='#C0392B';} d.textContent=msg; d.style.background=bg; d.style.color=col; // persist the score (best-effort; never blocks the UI) try { fetch('/chapter/' + CHAPTER_ID + '/quiz-record/', { method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json','X-CSRFToken':(document.cookie.match(/csrftoken=([^;]+)/)||[])[1]||''}, body: JSON.stringify({tool_kind:'test_yourself', score:score, max_score:MC.length, pct:fp}) }).catch(function(){}); } catch(e){} } }; wrap.appendChild(b); }); mount.appendChild(wrap); }); } })(); </script> </div> <footer class="site-footer"> <div class="ft-in"> <div class="ft-brand"> <img class="ft-logo-img" src="/static/img/logo.png?v=20260927" alt="Profplanet"> <p>CDACC notes, past papers and practicals for Kenyan TVET, written to the curriculum and kept accurate by the trainers who teach from them.</p> </div> <div class="ft-col"> <h4>Notes and papers</h4> <a href="/courses/">CDACC notes by course</a> <a href="/past-papers/">CDACC past papers</a> </div> <div class="ft-col"> <h4>For trainers</h4> <a href="/#what-you-get">Professional documents</a> <a href="/#how">How trainers earn access</a> </div> <div class="ft-col"> <h4>Account</h4> <a href="/login/">Log in</a> <a href="/signup/">Create account</a> </div> </div> <div class="ft-base"> <span>&copy; 2026 Profplanet &middot; CDACC notes and past papers for Kenyan TVET</span> <span class="ft-base-legal"> <a href="/privacy/">Privacy notice</a> <a href="/terms/">Terms of use</a> </span> <span>Past papers belong to their respective owners.</span> </div> </footer> <style> /* Gold is a light colour: white text on it is hard to read on a phone in daylight, so the button carries the dark brand blue instead. */ .hdr-cta { background: var(--accent); color: var(--primary) !important; font-weight: 700; } .hdr-cta:hover { background: var(--primary); color: var(--accent) !important; } .hdr-login { color: #fff !important; font-weight: 600; } .hdr-signup { background: var(--accent); color: var(--primary) !important; padding: 10px 22px !important; border-radius: 999px; font-weight: 700; margin-left: 8px; box-shadow: 0 4px 14px -4px rgba(200,127,10,.6); transition: transform .14s ease, box-shadow .16s ease, filter .16s ease; } .hdr-signup:hover { background: var(--primary); color: var(--accent) !important; transform: translateY(-1px); box-shadow: 0 6px 18px -6px rgba(5,10,48,.55); } .site-footer { margin-top: 0; background: #0F2032; color: #8C9BAC; border-top: 3px solid var(--accent); } .ft-in { max-width: 1160px; margin: 0 auto; padding: 56px 40px 40px; display: grid; grid-template-columns: 1.7fr 1fr 1fr 1fr; gap: 40px; } /* Four link columns now, not three. Between 640 and 900 the four tracks squeeze link text onto two lines, so drop to two columns there. */ @media (max-width: 900px) { .ft-in { grid-template-columns: 1fr 1fr; gap: 32px 28px; } } .ft-brand .ft-logo-img { display: block; height: 42px; width: auto; } .ft-brand p { font-size: 14px; line-height: 1.75; margin: 14px 0 0; max-width: 360px; color: #8C9BAC; } /* The one place --brand-cyan works: a small uppercase label on a dark ground, where the contrast that rules it out for body text does not apply. It is the logo's own colour and gives the footer an identity of its own. */ .ft-col h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 1.8px; color: var(--brand-cyan); margin: 0 0 16px; } .ft-col a { display: block; font-size: 14.5px; color: #AAB8C7; text-decoration: none; padding: 7px 0; transition: color .14s ease, padding-left .14s ease; } .ft-col a:hover { color: #fff; padding-left: 4px; } .ft-base { border-top: 1px solid rgba(255,255,255,.1); padding: 22px 40px; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; max-width: 1160px; margin: 0 auto; font-size: 12.5px; color: #6B7B8D; } .ft-base-legal a { color: #8C9BAC; text-decoration: none; margin-right: 18px; } .ft-base-legal a:hover { color: #fff; } @media (max-width: 640px) { .ft-base { flex-direction: column; text-align: center; gap: 10px; } } @media (max-width: 640px) { .ft-in { grid-template-columns: 1fr; gap: 24px; } } </style> </body> </html>